diff --git a/@types/Canvas.ts b/@types/Canvas.ts new file mode 100644 index 0000000..9bd9f51 --- /dev/null +++ b/@types/Canvas.ts @@ -0,0 +1,53 @@ +import { memo, ReactElement } from 'react'; + +export type PanState = { x: number, y: number }; + +export type CanvasProps = { + /** + * Since Canvas is a controlled component, the 'pan' prop defines the canvas panning + */ + pan?: PanState, + /** + * Since Canvas is a controlled component, the 'onPanChange' prop is the change handler of the 'pan' prop + */ + onPanChange?: (panState: PanState) => unknown, + /** + * Since Canvas is a controlled component, the 'zoom' prop defines its zoom level, aka: how much the canvas is scaling + */ + zoom?: number, + /** + * Since Canvas is a controlled component, the 'onZoomChange' prop is the change handler of the 'zoom' prop + */ + onZoomChange?: (zoom: number) => unknown, + /** + * Allow to zoom in/out on mouse wheel + */ + zoomOnWheel?: boolean, + /** + * The maximum allowed zoom + */ + maxZoom?: number, + /** + * The minimum allowed zoom + */ + minZoom?: number, + /** + * Defines whether the zoom should be reset on double click + */ + zoomResetOnDblClick?: boolean, + /** + * Defines whether the canvas should apply inertia when the drag is over + */ + inertia?: boolean, + /** + * Displays debug info + */ + debug?: boolean, + GridRenderer?: ReactElement, + ElementRenderer?: ReactElement, +} + + +declare const Canvas: (props: CanvasProps) => JSX.Element; + +export default memo(Canvas); diff --git a/@types/CanvasControls.ts b/@types/CanvasControls.ts new file mode 100644 index 0000000..964426e --- /dev/null +++ b/@types/CanvasControls.ts @@ -0,0 +1,20 @@ +import { memo, ElementType } from 'react'; +import { PanState } from './Canvas'; + + +export type CanvasControlsProps = { + placement?: 'top-left' | 'top-right' | 'top-center' | 'bottom-right' | 'bottom-center' | 'bottom-left' | 'left' | 'right', + alignment?: 'vertical' | 'horizontal', + onPanChange?: (panState: PanState) => unknown, + onZoomChange?: (zoom: PanState) => unknown, + ButtonRender?: ElementType, + ZoomInBtnRender?: ElementType, + CenterBtnRender?: ElementType, + ZoomOutBtnRender?: ElementType, + ElementRender?: ElementType, +} + + +declare const CanvasControls: (props: CanvasControlsProps) => JSX.Element; + +export default memo(CanvasControls); diff --git a/@types/useCanvasState.ts b/@types/useCanvasState.ts new file mode 100644 index 0000000..e2d7281 --- /dev/null +++ b/@types/useCanvasState.ts @@ -0,0 +1,16 @@ +import { PanState } from './Canvas'; + + +export type CanvasMethods = { + onPanChange: (panState: PanState) => unknown, + onZoomChange: (zoom: number) => unknown, +} + +export type CanvasStates = { + pan: PanState, + zoom: number, +} + +declare const useCanvasState: (initialStates?: CanvasStates) => [CanvasStates, CanvasMethods]; + +export default useCanvasState; diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f3bac..7c4f03e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,10 +130,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.5.0] - 2020-11-20 - ### Added +### Added + +- First implementation of draggable canvas +- First implementation of zoomable canvas + + +## [0.5.1] - 2020-11-25 + +### Added - - First implementation of draggable canvas - - First implementation of zoomable canvas +- Added `disconnect` function exported in `useSchema` hook ## [0.5.1] - 2020-11-27 @@ -142,3 +149,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reverted changes in `0.5.0` related to draggable canvas and zoomable canvas due to an uncaught bug. We will continue working on these features and release them in an upcoming version. Apologies everyone! + +## [0.6.0] - 2020-12-12 + +### Added + +- Canvas Component for panning and zooming +- useCanvas hook +- CanvasControl component diff --git a/docs/Basic-usage.md b/docs/Basic-usage.md new file mode 100644 index 0000000..51b6f54 --- /dev/null +++ b/docs/Basic-usage.md @@ -0,0 +1,55 @@ +To start using the library import a `Canvas` and a `Diagram` component, both are [controlled components](https://reactjs.org/docs/forms.html#controlled-components) +so you'll need to provide a [state](https://reactjs.org/docs/faq-state.html) and a [state handler](https://reactjs.org/docs/faq-state.html#how-do-i-update-state-with-values-that-depend-on-the-current-state) +(*beautiful-react-diagrams* exports mainly controlled components). + +A *Diagram* component needs to be wrapped into a *Canvas* which allows panning/zooming functionality.
+ +A *Diagram* can easily be represented by a "*schema*" (the library provides a set of pre-made utilities to define and validate schemas). +A "*schema*" is a plain object having, at least, a "*nodes*" property defined.
+ +The "*nodes*" property must be an array of tuples (objects) described by a unique "*id*" (if not provided the library will create a unique id for the node), +a "*content*" property (can be a React component) and a "*coordinates*" property describing the node position. + +Optionally a "*links*" property can be defined to define links between the nodes, similar to the "*nodes*" property it must +be an array of valid link describing tuples, a valid link must have an "*input*" and an "*output*" property. + +In order to avoid unnecessary complexity the `useSchema`, `useCanvasState` hooks have been provided together with the + `createSchema` utility. + +```js +import Diagram, { Canvas, createSchema, useSchema, useCanvasState, CanvasControls } from 'beautiful-react-diagrams'; + +// the diagram model +const initialSchema = createSchema({ + nodes: [ + { id: 'node-1', content: 'Hey Jude', coordinates: [312, 27], }, + { id: 'node-2', content: 'Don\'t', coordinates: [330, 90], }, + { id: 'node-3', content: 'be afraid', coordinates: [100, 320], }, + { id: 'node-4', content: 'let me down', coordinates: [306, 332], }, + { id: 'node-5', content: 'make it bad', coordinates: [515, 330], }, + ], + links: [ + { input: 'node-1', output: 'node-2' }, + { input: 'node-2', output: 'node-3' }, + { input: 'node-2', output: 'node-4' }, + { input: 'node-2', output: 'node-5' }, + ] +}); + +const DiagramExample = () => { + const [canvasState, handlers] = useCanvasState(); // creates canvas state + const [schema, { onChange }] = useSchema(initialSchema); // creates diagrams schema + + return ( +
+ + + + +
+ ); +}; + + +``` + diff --git a/docs/CanvasControls.md b/docs/CanvasControls.md new file mode 100644 index 0000000..1ae439f --- /dev/null +++ b/docs/CanvasControls.md @@ -0,0 +1,36 @@ +```js +import Diagram, { Canvas, createSchema, useSchema, useCanvasState, CanvasControls } from 'beautiful-react-diagrams'; + +// the diagram model +const initialSchema = createSchema({ + nodes: [ + { id: 'node-1', content: 'Hey Jude', coordinates: [312, 27], }, + { id: 'node-2', content: 'Don\'t', coordinates: [330, 90], }, + { id: 'node-3', content: 'be afraid', coordinates: [100, 320], }, + { id: 'node-4', content: 'let me down', coordinates: [306, 332], }, + { id: 'node-5', content: 'make it bad', coordinates: [515, 330], }, + ], + links: [ + { input: 'node-1', output: 'node-2' }, + { input: 'node-2', output: 'node-3' }, + { input: 'node-2', output: 'node-4' }, + { input: 'node-2', output: 'node-5' }, + ] +}); + +const DiagramExample = () => { + const [canvasState, handlers] = useCanvasState(); // creates canvas state + const [schema, { onChange }] = useSchema(initialSchema); // creates diagrams schema + + return ( +
+ + + + +
+ ); +}; + + +``` diff --git a/docs/Links-Ports.md b/docs/Links-Ports.md new file mode 100644 index 0000000..6392d35 --- /dev/null +++ b/docs/Links-Ports.md @@ -0,0 +1,120 @@ +### Ports + +```js +import Diagram, { Canvas, createSchema, useSchema, useCanvasState, CanvasControls } from 'beautiful-react-diagrams'; + +const initialSchema = createSchema({ + nodes: [ + { + id: 'node-1', + content: 'Start', + coordinates: [100, 150], + outputs: [ + { id: 'port-1', alignment: 'right' }, + { id: 'port-2', alignment: 'right' }, + ], + disableDrag: true, + data: { + foo: 'bar', + count: 0, + } + }, + { + id: 'node-2', + content: 'Middle', + coordinates: [300, 150], + inputs: [ + { id: 'port-3', alignment: 'left' }, + { id: 'port-4', alignment: 'left' }, + ], + outputs: [ + { id: 'port-5', alignment: 'right' }, + { id: 'port-6', alignment: 'right' }, + ], + data: { + bar: 'foo', + } + }, + { + id: 'node-3', + content: 'End', + coordinates: [600, 150], + inputs: [ + { id: 'port-7', alignment: 'left' }, + { id: 'port-8', alignment: 'left' }, + ], + data: { + foo: true, + bar: false, + some: { + deep: { + object: true, + } + }, + } + }, + ], + links: [ + { input: 'port-1', output: 'port-4' }, + ] +}); + +const UncontrolledDiagram = () => { + const [canvasState, handlers] = useCanvasState(); // creates canvas state + const [schema, { onChange }] = useSchema(initialSchema); // creates diagrams schema + + return ( +
+ + + + +
+ ); +}; + + +``` + +### Readonly Links + +```js static +import Diagram, { Canvas, createSchema, useSchema, useCanvasState, CanvasControls } from 'beautiful-react-diagrams'; + +// the diagram model +const initialSchema = createSchema({ + nodes: [ + { id: 'node-1', content: 'Hey Jude', coordinates: [312, 27], }, + { id: 'node-2', content: 'Don\'t', coordinates: [330, 90], }, + { id: 'node-3', content: 'be afraid', coordinates: [100, 320], }, + { id: 'node-4', content: 'let me down', coordinates: [306, 332], }, + { id: 'node-5', content: 'make it bad', coordinates: [515, 330], }, + { id: 'node-6', content: 'Take a sad song', coordinates: [295, 460], }, + ], + links: [ + { input: 'node-1', output: 'node-2', readonly: true, className: 'my-custom-link-class' }, + { input: 'node-2', output: 'node-3', readonly: true }, + { input: 'node-2', output: 'node-4', readonly: true }, + { input: 'node-2', output: 'node-5', readonly: true }, + { input: 'node-3', output: 'node-6', readonly: true }, + { input: 'node-4', output: 'node-6', readonly: true }, + { input: 'node-5', output: 'node-6', readonly: true }, + ] +}); + +const DiagramExample = () => { + const [canvasState, handlers] = useCanvasState(); // creates canvas state + const [schema, { onChange }] = useSchema(initialSchema); // creates diagrams schema + + return ( +
+ + + + +
+ ); +}; + + +``` diff --git a/docs/schema.md b/docs/Schema-utils.md similarity index 92% rename from docs/schema.md rename to docs/Schema-utils.md index 993ab99..a9f5044 100644 --- a/docs/schema.md +++ b/docs/Schema-utils.md @@ -1,4 +1,4 @@ -Managing complex and large schemas could be a problem, for this reason a set of function to handle the schema object +Managing complex and large schemas could be a hard thing to do, for this reason a set of function to handle the schema comes with the library. ### createSchema diff --git a/docs/basic-usage.md b/docs/basic-usage.md deleted file mode 100644 index e7e3ee3..0000000 --- a/docs/basic-usage.md +++ /dev/null @@ -1,39 +0,0 @@ -To start a diagram a valid schema shall be provided to the component via the `schema` prop.
-A valid model is a plain object having a `nodes` property set.
- -The `nodes` property is an array of javascript objects described by a unique `id` (it must be unique), -a `content` property (can be a React component) and a `coordinates` property describing the node position.

-Optionally a `links` property can be set describing links between the nodes, similar to the `nodes` property it must -be an array of valid link describing tuples, a valid link must have an `input` and an `output` property. - -```jsx -import Diagram, { createSchema, useSchema } from 'beautiful-react-diagrams'; - -// the diagram model -const initialSchema = createSchema({ - nodes: [ - { id: 'node-1', content: 'Node 1', coordinates: [250, 60], }, - { id: 'node-2', content: 'Node 2', coordinates: [100, 200], }, - { id: 'node-3', content: 'Node 3', coordinates: [250, 220], }, - { id: 'node-4', content: 'Node 4', coordinates: [400, 200], }, - ], - links: [ - { input: 'node-1', output: 'node-2' }, - { input: 'node-1', output: 'node-3' }, - { input: 'node-1', output: 'node-4' }, - ] -}); - -const UncontrolledDiagram = () => { - // create diagrams schema - const [schema, { onChange }] = useSchema(initialSchema); - - return ( -
- -
- ); -}; - - -``` diff --git a/docs/customisation.md b/docs/customisation.md index d2b13c9..8658454 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -1,6 +1,7 @@ ```js -import Diagram, { createSchema, useSchema } from 'beautiful-react-diagrams'; +import Diagram, { Canvas, createSchema, useSchema, useCanvasState, CanvasControls } from 'beautiful-react-diagrams'; +// Custom Node const CustomNode = (props) => { const { inputs } = props; @@ -36,15 +37,19 @@ const initialSchema = createSchema({ }); const UncontrolledDiagram = () => { - // create diagrams schema + const [canvasState, handlers] = useCanvasState(); const [schema, { onChange }] = useSchema(initialSchema); return ( -
- +
+ + + +
); }; -``` +```` + diff --git a/docs/dynamic-nodes.md b/docs/dynamic-nodes.md index ae4ca91..5f4d935 100644 --- a/docs/dynamic-nodes.md +++ b/docs/dynamic-nodes.md @@ -1,5 +1,5 @@ -```jsx -import Diagram, { createSchema, useSchema } from 'beautiful-react-diagrams'; +```js +import Diagram, { Canvas, createSchema, useSchema, useCanvasState, CanvasControls } from 'beautiful-react-diagrams'; import { Button } from 'beautiful-react-ui'; const initialSchema = createSchema({ @@ -31,7 +31,9 @@ const CustomRender = ({ id, content, data, inputs, outputs }) => ( const UncontrolledDiagram = () => { // create diagrams schema const [schema, { onChange, addNode, removeNode }] = useSchema(initialSchema); - + const [canvasStates, handlers] = useCanvasState(); // creates canvas state + + const deleteNodeFromSchema = (id) => { const nodeToRemove = schema.nodes.find(node => node.id === id); removeNode(nodeToRemove); @@ -52,12 +54,15 @@ const UncontrolledDiagram = () => { }; addNode(nextNode); - } + }; return (
- + + + +
); }; diff --git a/docs/links.md b/docs/links.md deleted file mode 100644 index 3156a8b..0000000 --- a/docs/links.md +++ /dev/null @@ -1,32 +0,0 @@ -### Standard Links - -```js -import Diagram, { useSchema, createSchema } from 'beautiful-react-diagrams'; - -const initialSchema = createSchema({ - nodes: [ - { id: 'node-1', content: 'Node 1', coordinates: [250, 60], }, - { id: 'node-2', content: 'Node 2', coordinates: [100, 200], }, - { id: 'node-3', content: 'Node 3', coordinates: [250, 220], }, - { id: 'node-4', content: 'Node 4', coordinates: [400, 200], }, - ], - links: [ - { input: 'node-1', output: 'node-2', label: 'Link 1', readonly: true }, - { input: 'node-1', output: 'node-3', label: 'Link 2', readonly: true }, - { input: 'node-1', output: 'node-4', label: 'Link 3', readonly: true, className: 'my-custom-link-class' }, - ] -}); - -const UncontrolledDiagram = () => { - // create diagrams schema - const [schema, { onChange }] = useSchema(initialSchema); - - return ( -
- -
- ); -}; - - -``` diff --git a/docs/hooks.md b/docs/other-libraries.md similarity index 100% rename from docs/hooks.md rename to docs/other-libraries.md diff --git a/docs/ports.md b/docs/ports.md deleted file mode 100644 index fe1cd96..0000000 --- a/docs/ports.md +++ /dev/null @@ -1,72 +0,0 @@ -```js -import Diagram, { useSchema, createSchema } from 'beautiful-react-diagrams'; - -const initialSchema = createSchema({ - nodes: [ - { - id: 'node-1', - content: 'Start', - coordinates: [100, 150], - outputs: [ - { id: 'port-1', alignment: 'right' }, - { id: 'port-2', alignment: 'right' }, - ], - disableDrag: true, - data: { - foo: 'bar', - count: 0, - } - }, - { - id: 'node-2', - content: 'Middle', - coordinates: [300, 150], - inputs: [ - { id: 'port-3', alignment: 'left' }, - { id: 'port-4', alignment: 'left' }, - ], - outputs: [ - { id: 'port-5', alignment: 'right' }, - { id: 'port-6', alignment: 'right' }, - ], - data: { - bar: 'foo', - } - }, - { - id: 'node-3', - content: 'End', - coordinates: [600, 150], - inputs: [ - { id: 'port-7', alignment: 'left' }, - { id: 'port-8', alignment: 'left' }, - ], - data: { - foo: true, - bar: false, - some: { - deep: { - object: true, - } - }, - } - }, - ], - links: [ - { input: 'port-1', output: 'port-4' }, - ] -}); - -const UncontrolledDiagram = () => { - // create diagrams schema - const [schema, { onChange }] = useSchema(initialSchema); - - return ( -
- -
- ); -}; - - -``` diff --git a/docs/setup/CustomComponentListRenderer.js b/docs/setup/CustomComponentListRenderer.js index 39dad86..ece3133 100644 --- a/docs/setup/CustomComponentListRenderer.js +++ b/docs/setup/CustomComponentListRenderer.js @@ -6,9 +6,8 @@ const SidebarItem = (props) => { return ( <> - {visibleName === 'Hooks' && } - {visibleName === 'Concepts' && } + {['Concepts', 'Dynamic nodes', 'Schema utilities', 'useCanvasState'].includes(visibleName) && } ); }; diff --git a/docs/setup/styleguidist.config.js b/docs/setup/styleguidist.config.js index 8e6132a..5021e9b 100644 --- a/docs/setup/styleguidist.config.js +++ b/docs/setup/styleguidist.config.js @@ -12,7 +12,7 @@ module.exports = { text: 'Fork me on GitHub', }, styleguideDir: '../../dist-ghpages', - exampleMode: 'collapse', + exampleMode: 'expand', usageMode: 'collapse', pagePerSection: true, sortProps: props => props, @@ -29,23 +29,18 @@ module.exports = { sectionDepth: 1, }, { - name: 'Diagram Component', - content: '../../src/Diagram/README.md', + name: 'Basic Usage', + content: '../Basic-usage.md', sectionDepth: 1, }, { - name: 'Schema', - content: '../schema.md', + name: 'Links and Ports', + content: '../Links-Ports.md', sectionDepth: 1, }, { - name: 'Linking nodes', - content: '../links.md', - sectionDepth: 1, - }, - { - name: 'Ports', - content: '../ports.md', + name: 'Canvas Controls', + content: '../CanvasControls.md', sectionDepth: 1, }, { @@ -53,16 +48,29 @@ module.exports = { content: '../customisation.md', sectionDepth: 1, }, - - { divider: true }, { name: 'Dynamic nodes', content: '../dynamic-nodes.md', sectionDepth: 1, }, { - name: 'Hooks', - content: '../hooks.md', + name: 'Schema utilities', + content: '../Schema-utils.md', + sectionDepth: 1, + }, + { + name: 'useSchema', + content: '../useSchema.md', + sectionDepth: 1, + }, + { + name: 'useCanvasState', + content: '../useCanvasState.md', + sectionDepth: 1, + }, + { + name: 'Other libraries', + content: '../other-libraries.md', sectionDepth: 1, }, ], diff --git a/docs/setup/styleguidist.theme.js b/docs/setup/styleguidist.theme.js index f4b8566..831b654 100644 --- a/docs/setup/styleguidist.theme.js +++ b/docs/setup/styleguidist.theme.js @@ -37,14 +37,14 @@ module.exports = { sidebar: { border: 0, width: '16rem', - background: 'white', + background: '#FBFAF9', boxShadow: '0 0 20px 0 rgba(20, 20, 20, 0.1)', }, content: { - maxWidth: '100%', + maxWidth: '1024px', }, root: { - background: '#FBFAF9', + background: 'white', }, hasSidebar: { paddingLeft: '16rem', diff --git a/docs/setup/styleguidist.webpack.js b/docs/setup/styleguidist.webpack.js index 2d67519..693e793 100644 --- a/docs/setup/styleguidist.webpack.js +++ b/docs/setup/styleguidist.webpack.js @@ -6,25 +6,16 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const sourcePath = path.resolve(__dirname, '../..', 'src'); module.exports = () => ({ - entry: [ - `${sourcePath}/theme/index.scss`, - `${sourcePath}/index.js`, - ], devtool: 'inline-source-map', - output: { - filename: 'beautiful-react-diagrams.dev.js', - }, resolve: { extensions: ['.js', '.jsx', 'scss'], alias: { 'beautiful-react-diagrams': sourcePath }, }, devServer: { + contentBase: sourcePath, open: true, - hot: false, - liveReload: true, - watchContentBase: true, + hot: true, }, - mode: 'development', module: { rules: [ { @@ -32,21 +23,18 @@ module.exports = () => ({ exclude: /node_modules/, use: { loader: 'babel-loader', + options: { + presets: [ + '@babel/preset-env', + '@babel/preset-react' + ] + } }, }, { test: /\.(css|scss)$/, exclude: /node_modules/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: 'css-loader', - options: { - importLoaders: 2, - }, - }, - { loader: 'sass-loader' }, - ], + use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], }, { test: /\.png$/, diff --git a/docs/useCanvasState.md b/docs/useCanvasState.md new file mode 100644 index 0000000..0b87248 --- /dev/null +++ b/docs/useCanvasState.md @@ -0,0 +1,21 @@ +Since *Canvas* is a [controlled components](https://reactjs.org/docs/forms.html#controlled-components), it needs to +be provided with a "*pan*" and a "*zoom*" states, and an "*onPanChange*" and an "*onZoomChange" handlers. + +Being a *controlled component* allows extreme flexibility in manipulating the *Canvas* state at runtime, +on the other hand, the operations performed on its states are quite often the same. + +For this reason I've summed up the most common operations in the `useCanvasState` hook. + +```typescript static +type CanvasMethods = { + onPanChange: (panState: PanState) => unknown, + onZoomChange: (zoom: number) => unknown, +} + +type CanvasStates = { + pan: PanState, + zoom: number, +} + +declare const useCanvasState: (initialStates: CanvasStates) => [CanvasStates, CanvasMethods]; +``` diff --git a/docs/useSchema.md b/docs/useSchema.md new file mode 100644 index 0000000..0639b2a --- /dev/null +++ b/docs/useSchema.md @@ -0,0 +1,17 @@ +Since *Diagram* is a [controlled components](https://reactjs.org/docs/forms.html#controlled-components), it needs to +be provided with a "*schema*", which represents its state, and an "*onChange*" handler. + +Being a *controlled component* allows extreme flexibility in manipulating the schema at runtime, on the other hand, the +operations performed on a schema are quite often the same. For this reason I've summed up the most common operations +in the `useSchema` hook. + +```typescript static +type DiagramMethods

= { + onChange: (schemaChanges: DiagramSchema

) => undefined; + addNode: (node: Node

) => undefined; + removeNode: (node: Node

) => undefined; + connect: (inputId: string, outputId: string) => undefined; +}; + +declare const useSchema:

(initialSchema: DiagramSchema

) => [DiagramSchema

, DiagramMethods

]; +``` diff --git a/index.d.ts b/index.d.ts index 5df0d34..1b936c0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,6 +1,10 @@ -import Diagram from "./@types/Diagram"; -import useSchema from "./@types/useSchema"; -import createSchema from "./@types/createSchema"; +import Diagram from './@types/Diagram'; +import useSchema from './@types/useSchema'; +import createSchema from './@types/createSchema'; +import Canvas from './@types/Canvas'; +import CanvasControls from './@types/CanvasControls'; +import useCanvasState from './@types/useCanvasState'; + import { validateSchema, validatePort, @@ -8,7 +12,7 @@ import { validateNodes, validateNode, validateLinks, -} from "./@types/validators"; +} from './@types/validators'; export { Diagram, @@ -20,5 +24,8 @@ export { validateNodes, validateNode, validateLinks, + Canvas, + CanvasControls, + useCanvasState, }; export default Diagram; diff --git a/package.json b/package.json index cc8b910..34fe2d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "beautiful-react-diagrams", - "version": "0.5.1", + "version": "0.6.0", "description": "A tiny collection of lightweight React components to build diagrams with ease", "main": "index.js", "module": "esm/index.js", @@ -8,7 +8,7 @@ "scripts": { "build": "npx del-cli dist && rollup -c", "build-doc": "npx styleguidist build --config docs/setup/styleguidist.config.js", - "start": "styleguidist server --config docs/setup/styleguidist.config.js", + "start": "npx styleguidist server --config docs/setup/styleguidist.config.js", "lint-js": "eslint --ext .jsx,.js src/", "lint-tests": "eslint --ext .jsx,.js tests/", "lint-scss": "stylelint '**/*.scss'", @@ -31,18 +31,18 @@ }, "devDependencies": { "@babel/cli": "^7.12.1", - "@babel/core": "^7.12.3", + "@babel/core": "7.12.7", "@babel/polyfill": "^7.12.1", - "@babel/preset-env": "^7.12.1", + "@babel/preset-env": "7.12.7", "@babel/preset-react": "^7.12.1", "@babel/register": "^7.12.1", "@rollup/plugin-babel": "^5.2.1", - "@rollup/plugin-node-resolve": "^9.0.0", + "@rollup/plugin-node-resolve": "9.0.0", "@testing-library/react": "^11.1.0", "@types/react": "^16.9.53", "autoprefixer": "9.0.0", "babel-eslint": "^10.1.0", - "babel-loader": "^8.1.0", + "babel-loader": "8.2.1", "beautiful-react-ui": "^0.56.14", "chai": "^4.2.0", "css-loader": "^5.0.0", @@ -61,7 +61,7 @@ "jsdom-global": "^3.0.2", "mini-css-extract-plugin": "^1.1.1", "mocha": "^8.2.0", - "node-sass": "^4.14.1", + "node-sass": "4.14.1", "nyc": "^15.1.0", "postcss": "^8.1.2", "postcss-fixes": "^2.0.1", @@ -69,9 +69,9 @@ "postcss-preset-env": "^6.7.0", "postcss-will-change": "3.0.0", "postcss-will-change-transition": "^1.2.0", - "react": "^16.14.0", - "react-dom": "^16.12.0", - "react-styleguidist": "^11.1.0", + "react": "16.14.0", + "react-dom": "16.14.0", + "react-styleguidist": "11.1.3", "rollup": "^2.32.1", "rollup-plugin-postcss": "^3.1.8", "sass": "^1.27.0", @@ -84,8 +84,7 @@ "stylelint-scss": "^3.18.0", "typescript": "4.0.5", "url-loader": "^4.1.1", - "webpack": "4.44.0", - "webpack-cli": "4.1.0" + "webpack": "5.6.0" }, "dependencies": { "beautiful-react-hooks": "^0.31.0", @@ -96,7 +95,7 @@ "prop-types": "^15.7.2" }, "peerDependencies": { - "react": ">=16.14.0", - "react-dom": ">=16.12.0" + "react": ">=17.0.1", + "react-dom": ">=17.0.1" } } diff --git a/src/Context/DiagramContext.js b/src/Context/DiagramContext.js index db2b024..2f20459 100644 --- a/src/Context/DiagramContext.js +++ b/src/Context/DiagramContext.js @@ -1,3 +1,3 @@ import React from 'react'; -export default React.createContext({ canvas: null, ports: null, nodes: null }); +export default React.createContext({ canvas: null, ports: null, nodes: null, panVal: { x: 0, y: 0 }, scaleVal: 1 }); diff --git a/src/Diagram/README.md b/src/Diagram/README.md deleted file mode 100644 index 0f0afbb..0000000 --- a/src/Diagram/README.md +++ /dev/null @@ -1,39 +0,0 @@ -To start representing diagrams a valid model object shall be provided to the component via the `schema` prop.
-A valid model is a plain object having a `nodes` property set.
-The `nodes` property must be an array of tuples (objects) described by a unique `id` (it must be unique), -a `content` property (can be a React component) and a `coordinates` property describing the node position.

-Optionally a `links` property can be set describing links between the nodes, similar to the `nodes` property it must -be an array of valid link describing tuples, a valid link must have an `input` and an `output` property. - -```js -import Diagram, { createSchema, useSchema } from 'beautiful-react-diagrams'; - -// the diagram model -const initialSchema = createSchema({ - nodes: [ - { id: 'node-1', content: 'Node 1', coordinates: [250, 60], }, - { id: 'node-2', content: 'Node 2', coordinates: [100, 200], }, - { id: 'node-3', content: 'Node 3', coordinates: [250, 220], }, - { id: 'node-4', content: 'Node 4', coordinates: [400, 200], }, - ], - links: [ - { input: 'node-1', output: 'node-2' }, - { input: 'node-1', output: 'node-3' }, - { input: 'node-1', output: 'node-4' }, - ] -}); - -const UncontrolledDiagram = () => { - // create diagrams schema - const [schema, { onChange }] = useSchema(initialSchema); - - return ( -

- -
- ); -}; - - -``` - diff --git a/src/components/Canvas/BackgroundGrid.js b/src/components/Canvas/BackgroundGrid.js new file mode 100644 index 0000000..aaa6ae2 --- /dev/null +++ b/src/components/Canvas/BackgroundGrid.js @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; + +const parallaxRatio = 1.25; +const calcCoordinates = (x, y) => ([x * parallaxRatio, y * parallaxRatio]); +const calcTransformation = (x, y, scale) => (`scale(${scale}) translate(${x}, ${y})`); + +/** + * TODO: document me + */ +const BackgroundGrid = ({ translateX, translateY, scale, svgPatternColor, svgPatternOpacity }) => { + const [x, y] = useMemo(() => calcCoordinates(translateX, translateY), [translateX, translateY]); + const transformation = useMemo(() => calcTransformation(x, y, scale), [x, y, scale]); + + return ( + + + + + + + + + + + + + + ); +}; + +BackgroundGrid.propTypes = { + svgPatternColor: PropTypes.string, + svgPatternOpacity: PropTypes.number, + translateX: PropTypes.number, + translateY: PropTypes.number, + scale: PropTypes.number, +}; + +BackgroundGrid.defaultProps = { + svgPatternColor: 'black', + svgPatternOpacity: 0.5, + translateX: 0, + translateY: 0, + scale: 1, +}; + +export default React.memo(BackgroundGrid); diff --git a/src/components/Canvas/Canvas.js b/src/components/Canvas/Canvas.js new file mode 100644 index 0000000..4299410 --- /dev/null +++ b/src/components/Canvas/Canvas.js @@ -0,0 +1,108 @@ +import React, { useMemo, useRef } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import useCanvasPanHandlers from './useCanvasPanHandlers'; +import useCanvasZoomHandlers from './useCanvasZoomHandlers'; +import BackgroundGrid from './BackgroundGrid'; +import { noop } from '../../shared/Constants'; +import { filterControlsOut, enrichControls } from './childrenUtils'; + +import './canvas.scss'; + +const calcTransformation = (scale = 1, { x = 0, y = 0 }) => ({ + transform: `translate(${x}px, ${y}px) translateZ(0) scale(${scale})`, +}); + +/** + * @TODO: Document this component + */ +const Canvas = (props) => { + const { + pan, onPanChange, zoom, onZoomChange, maxZoom, minZoom, zoomOnWheel, inertia, zoomResetOnDblClick, + ElementRenderer, GridRenderer, debug, className, children, ...rest + } = props; + const elRef = useRef(); + const classList = useMemo(() => classNames('bi bi-diagram bi-diagram-canvas', className), [className]); + const style = useMemo(() => calcTransformation(zoom, pan), [zoom, pan.x, pan.y]); + const startPan = useCanvasPanHandlers({ pan, onPanChange, inertia }); + + useCanvasZoomHandlers(elRef, { onZoomChange, maxZoom, minZoom, zoomOnWheel, zoomResetOnDblClick }); + + return ( + + +
+ {filterControlsOut(children, pan, zoom)} +
+ {debug && ( +
+

{`Pan: ${pan.x}, ${pan.y}`}

+

{`Scale: ${zoom}`}

+
+ )} + {enrichControls(children, { onPanChange, onZoomChange, minZoom, maxZoom })} +
+ ); +}; + +Canvas.propTypes = { + /** + * Since Canvas is a controlled component, the 'pan' prop defines the canvas panning + */ + pan: PropTypes.exact({ x: PropTypes.number, y: PropTypes.number }), + /** + * Since Canvas is a controlled component, the 'onPanChange' prop is the change handler of the 'pan' prop + */ + onPanChange: PropTypes.func, + /** + * Since Canvas is a controlled component, the 'zoom' prop defines its zoom level, aka: how much the canvas is scaling + */ + zoom: PropTypes.number, + /** + * Since Canvas is a controlled component, the 'onZoomChange' prop is the change handler of the 'zoom' prop + */ + onZoomChange: PropTypes.func, + /** + * Allow to zoom in/out on mouse wheel + */ + zoomOnWheel: PropTypes.bool, + /** + * The maximum allowed zoom + */ + maxZoom: PropTypes.number, + /** + * The minimum allowed zoom + */ + minZoom: PropTypes.number, + /** + * Defines whether the zoom should be reset on double click + */ + zoomResetOnDblClick: PropTypes.bool, + /** + * Defines whether the canvas should apply inertia when the drag is over + */ + inertia: PropTypes.bool, + /** + * Displays debug info + */ + debug: PropTypes.bool, + GridRenderer: PropTypes.elementType, + ElementRenderer: PropTypes.elementType, +}; + +Canvas.defaultProps = { + pan: { x: 0, y: 0 }, + onPanChange: noop, + zoom: 1, + onZoomChange: noop, + zoomOnWheel: true, + maxZoom: 2, + minZoom: 0.5, + zoomResetOnDblClick: true, + inertia: true, + debug: false, + GridRenderer: BackgroundGrid, + ElementRenderer: 'div', +}; + +export default React.memo(Canvas); diff --git a/src/components/Canvas/canvas.scss b/src/components/Canvas/canvas.scss new file mode 100644 index 0000000..dd75e32 --- /dev/null +++ b/src/components/Canvas/canvas.scss @@ -0,0 +1,39 @@ +.bi.bi-diagram.bi-diagram-canvas { + position: relative; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + background: #fbfaf9; + border: 0.07rem solid rgba(0, 0, 0, 0.2); + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab; + + &:active { + cursor: grabbing; + } + + .bi-canvas-content { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + .bi-canvas-debugger { + max-width: 24rem; + height: 3rem; + position: absolute; + bottom: 0; + right: 0; + background: rgba(0, 0, 0, 0.1); + + p { + margin: 0; + } + } +} diff --git a/src/components/Canvas/childrenUtils.js b/src/components/Canvas/childrenUtils.js new file mode 100644 index 0000000..1d1c6cc --- /dev/null +++ b/src/components/Canvas/childrenUtils.js @@ -0,0 +1,20 @@ +import React, { Children, cloneElement } from 'react'; +import CanvasControls from '../CanvasControls'; + +// todo: document this method +export const filterControlsOut = (children, pan, scale) => Children.map(children, (C) => (C.type !== CanvasControls + ? React.cloneElement(C, { pan, scale }) + : null)); + +// todo: document this method +export const enrichControls = (children, props) => Children.map(children, (C) => { + if (C.type === CanvasControls) { + return cloneElement(C, { + onPanChange: C.props.onPanChange || props.onPanChange, + onZoomChange: C.props.onZoomChange || props.onZoomChange, + minZoom: C.props.minZoom || props.minZoom, + maxZoom: C.props.maxZoom || props.maxZoom, + }); + } + return null; +}); diff --git a/src/components/Canvas/index.js b/src/components/Canvas/index.js new file mode 100644 index 0000000..dd22256 --- /dev/null +++ b/src/components/Canvas/index.js @@ -0,0 +1 @@ +export { default } from './Canvas'; diff --git a/src/components/Canvas/useCanvasPanHandlers.js b/src/components/Canvas/useCanvasPanHandlers.js new file mode 100644 index 0000000..79e0756 --- /dev/null +++ b/src/components/Canvas/useCanvasPanHandlers.js @@ -0,0 +1,73 @@ +import { useCallback, useRef } from 'react'; +import { Events, isTouch } from '../../shared/Constants'; + +const friction = 0.8; // TODO: document this stuff +const getMouseEventPoint = (e) => ({ x: e.pageX, y: e.pageY }); +const getTouchEventPoint = (e) => getMouseEventPoint(e.changedTouches[0]); +const getEventPoint = isTouch ? getTouchEventPoint : getMouseEventPoint; +const calculateDelta = (current, last) => ({ x: last.x - current.x, y: last.y - current.y }); +const applyInertia = (value) => (Math.abs(value) >= 0.5 ? Math.trunc(value * friction) : 0); + +/** + * TODO: document this thing + * Inspired by this article: + * https://jclem.net/posts/pan-zoom-canvas-react?utm_campaign=building-a-s--zoomable-canvasdi + */ +const useCanvasPanHandlers = ({ pan, onPanChange, inertia }) => { + const lastPointRef = useRef(pan); + const deltaRef = useRef({ x: null, y: null }); + + // TODO: document this callback + const performPan = useCallback((event) => { + if (onPanChange) { + const lastPoint = { ...lastPointRef.current }; + const point = getEventPoint(event); + lastPointRef.current = point; + onPanChange(({ x, y }) => { + const delta = calculateDelta(lastPoint, point); + deltaRef.current = { ...delta }; + + return { x: x + delta.x, y: y + delta.y }; + }); + } + }, []); + + // TODO: document this callback + const performInertia = useCallback(() => { + if (inertia) { + onPanChange(({ x, y }) => ({ x: x + deltaRef.current.x, y: y + deltaRef.current.y })); + + deltaRef.current.x = applyInertia(deltaRef.current.x); + deltaRef.current.y = applyInertia(deltaRef.current.y); + + if (Math.abs(deltaRef.current.x) > 0 || Math.abs(deltaRef.current.y) > 0) { + requestAnimationFrame(performInertia); + } + } + }, [inertia, deltaRef.current.x, deltaRef.current.y]); + + // TODO: document this callback + const endPan = useCallback(() => { + if (onPanChange) { + document.removeEventListener(Events.MOUSE_MOVE, performPan); + document.removeEventListener(Events.MOUSE_END, endPan); + + if (inertia) { + requestAnimationFrame(performInertia); + } + } + }, [performPan, inertia, onPanChange]); + + // TODO: document this callback + const onPanStart = useCallback((event) => { + if (onPanChange) { + document.addEventListener(Events.MOUSE_MOVE, performPan); + document.addEventListener(Events.MOUSE_END, endPan); + lastPointRef.current = getEventPoint(event); + } + }, [onPanChange, performPan, endPan]); + + return onPanStart; +}; + +export default useCanvasPanHandlers; diff --git a/src/components/Canvas/useCanvasZoomHandlers.js b/src/components/Canvas/useCanvasZoomHandlers.js new file mode 100644 index 0000000..afe8c21 --- /dev/null +++ b/src/components/Canvas/useCanvasZoomHandlers.js @@ -0,0 +1,54 @@ +import { useEffect, useCallback } from 'react'; +import { Events } from '../../shared/Constants'; + +// TODO: move to the hooks library +const useEvent = (ref, event, callback, options) => { + useEffect(() => { + if (ref.current) { + ref.current.addEventListener(event, callback, options); + } + + return () => { + if (ref.current) { + ref.current.removeEventListener(event, callback, options); + } + }; + }, [ref.current]); +}; + +const defaultOptions = { zoom: 1, maxZoom: 5, minZoom: 0.4 }; +const wheelOffset = 0.01; // TODO: document this + +/** + * TODO: document this thing + * inspired by: https://jclem.net/posts/pan-zoom-canvas-react?utm_campaign=building-a-pannable--zoomable-canvasdi + */ +const useCanvasZoomHandlers = (ref, options = defaultOptions) => { + const { onZoomChange, maxZoom, minZoom, zoomOnWheel, zoomResetOnDblClick } = options; + + const scaleOnWheel = useCallback((event) => { + if (onZoomChange && zoomOnWheel) { + event.preventDefault(); // FIXME: double check the bubbling of this event you know nothing about + + onZoomChange((currentScale) => { + if (event.deltaY > 0) { + return (currentScale + wheelOffset < maxZoom) ? (currentScale + wheelOffset) : maxZoom; + } + + return (currentScale - wheelOffset > minZoom) ? (currentScale - wheelOffset) : minZoom; + }); + } + }, [onZoomChange, maxZoom, minZoom]); + + const resetZoom = useCallback((event) => { + if (onZoomChange && zoomResetOnDblClick) { + event.preventDefault(); + onZoomChange(1); + } + }, []); + + useEvent(ref, Events.WHEEL, scaleOnWheel, { passive: false }); + useEvent(ref, Events.DOUBLE_CLICK, resetZoom, { passive: false }); +}; + +export default useCanvasZoomHandlers; diff --git a/src/components/CanvasControls/CanvasControls.js b/src/components/CanvasControls/CanvasControls.js new file mode 100644 index 0000000..d5da6bb --- /dev/null +++ b/src/components/CanvasControls/CanvasControls.js @@ -0,0 +1,73 @@ +import React, { useMemo, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import PlusIcon from './IconPlus'; +import MinusIcon from './IconMinus'; +import CenterIcon from './IconCenter'; +import { noop } from '../../shared/Constants'; + +import './canvas-controls.scss'; + +/** + * TODO: document this thing + * @param props + * @returns {*} + * @constructor + */ +const CanvasControls = (props) => { + const { + placement, alignment, onPanChange, onZoomChange, className, + ElementRender, ButtonRender, ZoomInBtnRender, ZoomOutBtnRender, CenterBtnRender, + } = props; + const classList = useMemo(() => ( + classNames('bi bi-diagram-ctrls', `bi-diagram-ctrls-${placement}`, `bi-diagram-ctrls-${alignment}`, className) + ), [placement, className, alignment]); + + const zoomInHandler = useCallback(() => { + onZoomChange((currentZoom) => (currentZoom + 0.25)); + }, [onZoomChange]); + + const zoomOutHandler = useCallback(() => { + onZoomChange((currentZoom) => (currentZoom - 0.25)); + }, [onZoomChange]); + + const resetHandler = useCallback(() => { + onPanChange({ x: 0, y: 0 }); + onZoomChange(1); + }, [onZoomChange, onPanChange]); + + return ( + + + + + + ); +}; + +CanvasControls.propTypes = { + // eslint-disable-next-line max-len + placement: PropTypes.oneOf(['top-left', 'top-right', 'top-center', 'bottom-right', 'bottom-center', 'bottom-left', 'left', 'right']), + alignment: PropTypes.oneOf(['vertical', 'horizontal']), + onPanChange: PropTypes.func, + onZoomChange: PropTypes.func, + ButtonRender: PropTypes.elementType, + ZoomInBtnRender: PropTypes.elementType, + CenterBtnRender: PropTypes.elementType, + ZoomOutBtnRender: PropTypes.elementType, + ElementRender: PropTypes.elementType, +}; + +CanvasControls.defaultProps = { + placement: 'bottom-left', + alignment: 'vertical', + onPanChange: noop, + onZoomChange: noop, + ButtonRender: 'button', + ZoomInBtnRender: PlusIcon, + CenterBtnRender: CenterIcon, + ZoomOutBtnRender: MinusIcon, + ElementRender: 'nav', +}; + +export default React.memo(CanvasControls); diff --git a/src/components/CanvasControls/IconCenter.js b/src/components/CanvasControls/IconCenter.js new file mode 100644 index 0000000..8ed33d3 --- /dev/null +++ b/src/components/CanvasControls/IconCenter.js @@ -0,0 +1,20 @@ +import React from 'react'; + +/** + * // TODO: document this + * @returns {*} + * @constructor + */ +const IconCenter = () => ( + + + + + + + + + +); + +export default React.memo(IconCenter); diff --git a/src/components/CanvasControls/IconMinus.js b/src/components/CanvasControls/IconMinus.js new file mode 100644 index 0000000..66a6f13 --- /dev/null +++ b/src/components/CanvasControls/IconMinus.js @@ -0,0 +1,15 @@ +import React from 'react'; + +/** + * // TODO: document this + * @returns {*} + * @constructor + */ +const IconMinus = () => ( + + {/* eslint-disable-next-line max-len */} + + +); + +export default React.memo(IconMinus); diff --git a/src/components/CanvasControls/IconPlus.js b/src/components/CanvasControls/IconPlus.js new file mode 100644 index 0000000..dfea926 --- /dev/null +++ b/src/components/CanvasControls/IconPlus.js @@ -0,0 +1,15 @@ +import React from 'react'; + +/** + * // TODO: document this + * @returns {*} + * @constructor + */ +const IconPlus = () => ( + + {/* eslint-disable-next-line max-len */} + + +); + +export default React.memo(IconPlus); diff --git a/src/components/CanvasControls/canvas-controls.scss b/src/components/CanvasControls/canvas-controls.scss new file mode 100644 index 0000000..eebab77 --- /dev/null +++ b/src/components/CanvasControls/canvas-controls.scss @@ -0,0 +1,71 @@ +.bi.bi-diagram-ctrls { + box-sizing: border-box; + position: absolute; + padding: 0.75rem; + display: flex; + + .bid-ctrls-btn { + width: 1rem; + height: 1rem; + padding: 0.3rem; + background: white; + border: 0.07rem solid rgba(0, 0, 0, 0.1); + box-shadow: 0.12rem 0.24rem 1rem rgba(0, 0, 0, 0.1); + box-sizing: content-box; + cursor: pointer; + } + + // Alignments + &.bi-diagram-ctrls-vertical { + flex-direction: column; + } + + &.bi-diagram-ctrls-horizontal { + flex-direction: row; + } + + // Placements + &.bi-diagram-ctrls-left { + left: 0; + top: 50%; + transform: translateY(-50%); + } + + &.bi-diagram-ctrls-right { + right: 0; + top: 50%; + transform: translateY(-50%); + } + + &.bi-diagram-ctrls-top-left { + top: 0; + left: 0; + } + + &.bi-diagram-ctrls-top-right { + top: 0; + right: 0; + } + + &.bi-diagram-ctrls-top-center { + top: 0; + left: 50%; + transform: translateX(-50%); + } + + &.bi-diagram-ctrls-bottom-right { + bottom: 0; + right: 0; + } + + &.bi-diagram-ctrls-bottom-center { + bottom: 0; + left: 50%; + transform: translateX(-50%); + } + + &.bi-diagram-ctrls-bottom-left { + bottom: 0; + left: 0; + } +} diff --git a/src/components/CanvasControls/index.js b/src/components/CanvasControls/index.js new file mode 100644 index 0000000..26a0d48 --- /dev/null +++ b/src/components/CanvasControls/index.js @@ -0,0 +1 @@ +export { default } from './CanvasControls'; diff --git a/src/Diagram/Diagram.js b/src/components/Diagram/Diagram.js similarity index 90% rename from src/Diagram/Diagram.js rename to src/components/Diagram/Diagram.js index 68c46a6..7fe5e60 100644 --- a/src/Diagram/Diagram.js +++ b/src/components/Diagram/Diagram.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import DiagramCanvas from './DiagramCanvas/DiagramCanvas'; import NodesCanvas from './NodesCanvas/NodesCanvas'; import LinksCanvas from './LinksCanvas/LinksCanvas'; -import { SchemaType } from '../shared/Types'; +import { SchemaType } from '../../shared/Types'; import './diagram.scss'; @@ -14,7 +14,7 @@ import './diagram.scss'; * with the user. */ const Diagram = (props) => { - const { schema, onChange, ...rest } = props; + const { schema, onChange, pan, scale, ...rest } = props; const [segment, setSegment] = useState(); const { current: portRefs } = useRef({}); // keeps the port elements references const { current: nodeRefs } = useRef({}); // keeps the node elements references @@ -71,7 +71,7 @@ const Diagram = (props) => { }; return ( - + { - const { children, portRefs, nodeRefs, className, ...rest } = props; - const [bbox, setBoundingBox] = useState(null); + const { children, portRefs, nodeRefs, pan, scale, className, ...rest } = props; + const [bbox, setBoundingBox] = useState(); const canvasRef = useRef(); const classList = classNames('bi bi-diagram', className); // calculate the given element bounding box and save it into the bbox state - const calculateBBox = (el) => { - if (el) { - const nextBBox = el.getBoundingClientRect(); + const calculateBBox = () => { + if (canvasRef.current) { + const nextBBox = canvasRef.current.getBoundingClientRect(); if (!isEqual(nextBBox, bbox)) { setBoundingBox(nextBBox); } @@ -28,15 +28,16 @@ const DiagramCanvas = (props) => { // when the canvas is ready and placed within the DOM, save its bounding box to be provided down // to children component as a context value for future calculations. - useEffect(() => calculateBBox(canvasRef.current), [canvasRef.current]); + useEffect(calculateBBox, [canvasRef.current]); // same on window scroll and resize - useWindowScroll(() => calculateBBox(canvasRef.current)); - useWindowResize(() => calculateBBox(canvasRef.current)); + useWindowScroll(calculateBBox); + useWindowResize(calculateBBox); return (
-
- +
+ {/* eslint-disable-next-line max-len */} + {children}
@@ -48,12 +49,16 @@ DiagramCanvas.propTypes = { portRefs: PropTypes.shape({}), nodeRefs: PropTypes.shape({}), className: PropTypes.string, + pan: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }), + scale: PropTypes.number, }; DiagramCanvas.defaultProps = { portRefs: {}, nodeRefs: {}, className: '', + pan: { x: 0, y: 0 }, + scale: 1, }; export default React.memo(DiagramCanvas); diff --git a/src/Diagram/DiagramNode/DiagramNode.js b/src/components/Diagram/DiagramNode/DiagramNode.js similarity index 94% rename from src/Diagram/DiagramNode/DiagramNode.js rename to src/components/Diagram/DiagramNode/DiagramNode.js index fb5bd51..6baf2b5 100644 --- a/src/Diagram/DiagramNode/DiagramNode.js +++ b/src/components/Diagram/DiagramNode/DiagramNode.js @@ -2,11 +2,11 @@ import React, { useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import getDiagramNodeStyle from './getDiagramNodeStyle'; -import { usePortRegistration, useNodeRegistration } from '../../shared/internal_hooks/useContextRegistration'; -import { PortType } from '../../shared/Types'; +import { usePortRegistration, useNodeRegistration } from '../../../shared/internal_hooks/useContextRegistration'; +import { PortType } from '../../../shared/Types'; import portGenerator from './portGenerator'; -import useDrag from '../../shared/internal_hooks/useDrag'; -import useNodeUnregistration from '../../shared/internal_hooks/useNodeUnregistration'; +import useDrag from '../../../shared/internal_hooks/useDrag'; +import useNodeUnregistration from '../../../shared/internal_hooks/useNodeUnregistration'; /** * A Diagram Node component displays a single diagram node, handles the drag n drop business logic and fires the @@ -24,8 +24,9 @@ const DiagramNode = (props) => { if (!disableDrag) { // when drag starts, save the starting coordinates into the `dragStartPoint` ref - onDragStart(() => { + onDragStart((event) => { dragStartPoint.current = coordinates; + event.stopPropagation(); }); // whilst dragging calculates the next coordinates and perform the `onPositionChange` callback diff --git a/src/Diagram/DiagramNode/getDiagramNodeStyle.js b/src/components/Diagram/DiagramNode/getDiagramNodeStyle.js similarity index 100% rename from src/Diagram/DiagramNode/getDiagramNodeStyle.js rename to src/components/Diagram/DiagramNode/getDiagramNodeStyle.js diff --git a/src/Diagram/DiagramNode/portGenerator.js b/src/components/Diagram/DiagramNode/portGenerator.js similarity index 100% rename from src/Diagram/DiagramNode/portGenerator.js rename to src/components/Diagram/DiagramNode/portGenerator.js diff --git a/src/Diagram/Link/Link.js b/src/components/Diagram/Link/Link.js similarity index 70% rename from src/Diagram/Link/Link.js rename to src/components/Diagram/Link/Link.js index e31a1f4..565794e 100644 --- a/src/Diagram/Link/Link.js +++ b/src/components/Diagram/Link/Link.js @@ -1,22 +1,22 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { LinkType, NodeType, PortType } from '../../shared/Types'; -import usePortRefs from '../../shared/internal_hooks/usePortRefs'; -import useCanvas from '../../shared/internal_hooks/useCanvas'; +import { LinkType, NodeType, PortType } from '../../../shared/Types'; +import usePortRefs from '../../../shared/internal_hooks/usePortRefs'; +import useCanvas from '../../../shared/internal_hooks/useCanvas'; import getCoords from './getEntityCoordinates'; -import makeSvgPath from '../../shared/functions/makeSvgPath'; -import getPathMidpoint from '../../shared/functions/getPathMidpoint'; -import useNodeRefs from '../../shared/internal_hooks/useNodeRefs'; +import makeSvgPath from '../../../shared/functions/makeSvgPath'; +import getPathMidpoint from '../../../shared/functions/getPathMidpoint'; +import useNodeRefs from '../../../shared/internal_hooks/useNodeRefs'; import LinkLabel from './LinkLabel'; // local hook, returns portRefs & nodeRefs const useContextRefs = () => { - const canvas = useCanvas(); + const { canvas, panVal, scaleVal } = useCanvas(); const portRefs = usePortRefs(); const nodeRefs = useNodeRefs(); - return { canvas, nodeRefs, portRefs }; + return { canvas, panVal, scaleVal, nodeRefs, portRefs }; }; /** @@ -26,11 +26,12 @@ const Link = (props) => { const { input, output, link, onDelete } = props; const pathRef = useRef(); const [labelPosition, setLabelPosition] = useState(); - const { canvas, portRefs, nodeRefs } = useContextRefs(); - const inputPoint = useMemo(() => getCoords(input, portRefs, nodeRefs, canvas), [input, portRefs, nodeRefs, canvas]); + const { canvas, panVal, scaleVal, portRefs, nodeRefs } = useContextRefs(); + // eslint-disable-next-line max-len + const inputPoint = useMemo(() => getCoords(input, portRefs, nodeRefs, canvas, panVal), [input, portRefs, nodeRefs, canvas, panVal]); /* eslint-disable max-len */ const classList = useMemo(() => classNames('bi-diagram-link', { 'readonly-link': link.readonly }, link.className), [link.readonly, link.className]); - const outputPoint = useMemo(() => getCoords(output, portRefs, nodeRefs, canvas), [output, portRefs, nodeRefs, canvas]); + const outputPoint = useMemo(() => getCoords(output, portRefs, nodeRefs, canvas, panVal), [output, portRefs, nodeRefs, canvas, panVal]); /* eslint-enable max-len */ const pathOptions = { type: (input.type === 'port' || output.type === 'port') ? 'bezier' : 'curve', @@ -54,8 +55,13 @@ const Link = (props) => { } }, [link.readonly, onDelete]); + /** + * for nodes with ports and links it's required to recalculate the link scale based on canvas scale + */ + const nextScale = Object.keys(portRefs).length > 0 ? 1 / scaleVal : 1; + return ( - + {!link.readonly && ()} {link.label && labelPosition && ()} diff --git a/src/Diagram/Link/LinkLabel.js b/src/components/Diagram/Link/LinkLabel.js similarity index 100% rename from src/Diagram/Link/LinkLabel.js rename to src/components/Diagram/Link/LinkLabel.js diff --git a/src/Diagram/Link/getEntityCoordinates.js b/src/components/Diagram/Link/getEntityCoordinates.js similarity index 68% rename from src/Diagram/Link/getEntityCoordinates.js rename to src/components/Diagram/Link/getEntityCoordinates.js index 8b3e84c..e91ffee 100644 --- a/src/Diagram/Link/getEntityCoordinates.js +++ b/src/components/Diagram/Link/getEntityCoordinates.js @@ -1,20 +1,25 @@ -import getRelativePoint from '../../shared/functions/getRelativePoint'; +import getRelativePoint from '../../../shared/functions/getRelativePoint'; /** * Return the coordinates of a given entity (node or port) */ -const getEntityCoordinates = (entity, portRefs, nodeRefs, canvas) => { +// eslint-disable-next-line max-len +const getEntityCoordinates = (entity, portRefs, nodeRefs, canvas = { x: 0, y: 0 }, pan = { x: 0, y: 0 }) => { if (entity && entity.type === 'node' && nodeRefs[entity.entity.id]) { const nodeEl = nodeRefs[entity.entity.id]; const bbox = nodeEl.getBoundingClientRect(); + return [entity.entity.coordinates[0] + (bbox.width / 2), entity.entity.coordinates[1] + (bbox.height / 2)]; } if (portRefs && portRefs[entity.entity.id]) { + const nextX = canvas.x + pan.x; + const nextY = canvas.y + pan.y; + const nextCanvas = [nextX, nextY]; const portEl = portRefs[entity.entity.id]; const bbox = portEl.getBoundingClientRect(); - return getRelativePoint([bbox.x + (bbox.width / 2), bbox.y + (bbox.height / 2)], [canvas.x, canvas.y]); + return getRelativePoint([bbox.x + (bbox.width / 2), bbox.y + (bbox.height / 2)], [nextCanvas[0], nextCanvas[1]]); } return undefined; diff --git a/src/Diagram/LinksCanvas/LinksCanvas.js b/src/components/Diagram/LinksCanvas/LinksCanvas.js similarity index 95% rename from src/Diagram/LinksCanvas/LinksCanvas.js rename to src/components/Diagram/LinksCanvas/LinksCanvas.js index 503e343..f4208b4 100644 --- a/src/Diagram/LinksCanvas/LinksCanvas.js +++ b/src/components/Diagram/LinksCanvas/LinksCanvas.js @@ -2,7 +2,7 @@ import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import DiagramLink from '../Link/Link'; import Segment from '../Segment/Segment'; -import { LinkType, NodeType, PortAlignment } from '../../shared/Types'; +import { LinkType, NodeType, PortAlignment } from '../../../shared/Types'; import findInvolvedEntity from './findInvolvedEntity'; import removeLinkFromArray from './removeLinkFromArray'; diff --git a/src/Diagram/LinksCanvas/findInvolvedEntity.js b/src/components/Diagram/LinksCanvas/findInvolvedEntity.js similarity index 100% rename from src/Diagram/LinksCanvas/findInvolvedEntity.js rename to src/components/Diagram/LinksCanvas/findInvolvedEntity.js diff --git a/src/Diagram/LinksCanvas/removeLinkFromArray.js b/src/components/Diagram/LinksCanvas/removeLinkFromArray.js similarity index 100% rename from src/Diagram/LinksCanvas/removeLinkFromArray.js rename to src/components/Diagram/LinksCanvas/removeLinkFromArray.js diff --git a/src/Diagram/NodesCanvas/NodesCanvas.js b/src/components/Diagram/NodesCanvas/NodesCanvas.js similarity index 97% rename from src/Diagram/NodesCanvas/NodesCanvas.js rename to src/components/Diagram/NodesCanvas/NodesCanvas.js index 3bcb6ce..90cdd26 100644 --- a/src/Diagram/NodesCanvas/NodesCanvas.js +++ b/src/components/Diagram/NodesCanvas/NodesCanvas.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { NodeType } from '../../shared/Types'; +import { NodeType } from '../../../shared/Types'; import DiagramNode from '../DiagramNode/DiagramNode'; import updateNodeCoordinates from './updateNodeCoordinates'; diff --git a/src/Diagram/NodesCanvas/updateNodeCoordinates.js b/src/components/Diagram/NodesCanvas/updateNodeCoordinates.js similarity index 100% rename from src/Diagram/NodesCanvas/updateNodeCoordinates.js rename to src/components/Diagram/NodesCanvas/updateNodeCoordinates.js diff --git a/src/Diagram/Port/Port.js b/src/components/Diagram/Port/Port.js similarity index 83% rename from src/Diagram/Port/Port.js rename to src/components/Diagram/Port/Port.js index 9bba74e..d8cf174 100644 --- a/src/Diagram/Port/Port.js +++ b/src/components/Diagram/Port/Port.js @@ -1,8 +1,8 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import useDrag from '../../shared/internal_hooks/useDrag'; -import useCanvas from '../../shared/internal_hooks/useCanvas'; -import getRelativePoint from '../../shared/functions/getRelativePoint'; +import useDrag from '../../../shared/internal_hooks/useDrag'; +import useCanvas from '../../../shared/internal_hooks/useCanvas'; +import getRelativePoint from '../../../shared/functions/getRelativePoint'; /** * Port @@ -12,15 +12,15 @@ import getRelativePoint from '../../shared/functions/getRelativePoint'; */ const Port = (props) => { const { id, canLink, alignment, onDragNewSegment, onSegmentFail, onSegmentConnect, onMount, type, ...rest } = props; - const canvas = useCanvas(); + const { canvas, panVal } = useCanvas(); const { ref, onDrag, onDragEnd } = useDrag(); onDrag((event, info) => { if (onDragNewSegment) { event.stopImmediatePropagation(); event.stopPropagation(); - const from = getRelativePoint(info.start, [canvas.x, canvas.y]); - const to = getRelativePoint([event.clientX, event.clientY], [canvas.x, canvas.y]); + const from = getRelativePoint(info.start, [canvas.x + panVal.x, canvas.y + panVal.y]); + const to = getRelativePoint([event.clientX, event.clientY], [canvas.x + panVal.x, canvas.y + panVal.y]); onDragNewSegment(id, from, to, alignment); } diff --git a/src/Diagram/Segment/Segment.js b/src/components/Diagram/Segment/Segment.js similarity index 66% rename from src/Diagram/Segment/Segment.js rename to src/components/Diagram/Segment/Segment.js index f274309..fdea853 100644 --- a/src/Diagram/Segment/Segment.js +++ b/src/components/Diagram/Segment/Segment.js @@ -1,7 +1,8 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { PortAlignment } from '../../shared/Types'; -import makeSvgPath from '../../shared/functions/makeSvgPath'; +import { PortAlignment } from '../../../shared/Types'; +import makeSvgPath from '../../../shared/functions/makeSvgPath'; +import useCanvas from '../../../shared/internal_hooks/useCanvas'; /** * Segment @@ -10,9 +11,11 @@ const Segment = (props) => { const { from, to, alignment } = props; const pathOptions = { type: 'bezier', inputAlignment: alignment }; const path = useMemo(() => makeSvgPath(from, to, pathOptions), [from, to, alignment]); + const { scaleVal } = useCanvas(); + const nextScale = 1 / scaleVal; return ( - + diff --git a/src/Diagram/diagram.scss b/src/components/Diagram/diagram.scss similarity index 94% rename from src/Diagram/diagram.scss rename to src/components/Diagram/diagram.scss index ffe7fa0..be1782b 100644 --- a/src/Diagram/diagram.scss +++ b/src/components/Diagram/diagram.scss @@ -2,13 +2,13 @@ box-sizing: border-box; width: 100%; height: 100%; - border: 0.07rem solid #dae1e7; - border-radius: 0.25rem; - box-shadow: 0 0.8rem 1rem -0.2rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.5rem -0.02rem rgba(0, 0, 0, 0.05); - min-height: 100%; - background-color: #f8fafc; - position: relative; - overflow: hidden; + + .bi-diagram-inners { + box-sizing: border-box; + width: 100%; + height: 100%; + position: relative; + } // ---------------------------- // Diagram node general wrapper @@ -78,15 +78,19 @@ height: 100%; z-index: 0; position: absolute; + overflow: visible; top: 0; right: 0; bottom: 0; left: 0; + transform-origin: center; // ---------------------------- // Segment // ---------------------------- .bi-diagram-segment { + transform-origin: center; + path { stroke: #dae1e7; stroke-width: 0.25rem; @@ -108,6 +112,7 @@ // ---------------------------- .bi-diagram-link { pointer-events: stroke; + transform-origin: center; // link path .bi-link-path { diff --git a/src/Diagram/index.js b/src/components/Diagram/index.js similarity index 100% rename from src/Diagram/index.js rename to src/components/Diagram/index.js diff --git a/src/hooks/useCanvasState.js b/src/hooks/useCanvasState.js new file mode 100644 index 0000000..ba33fc4 --- /dev/null +++ b/src/hooks/useCanvasState.js @@ -0,0 +1,18 @@ +import { useState } from 'react'; + +const defaultInitialState = { + pan: { x: 0, y: 0 }, + zoom: 1, +}; + +/** + * TODO: document this thing as it was necessary + */ +const useCanvasState = (initialState = defaultInitialState) => { + const [pan, onPanChange] = useState(initialState.pan); + const [zoom, onZoomChange] = useState(initialState.zoom); + + return [{ pan, zoom }, { onPanChange, onZoomChange }]; +}; + +export default useCanvasState; diff --git a/src/hooks/useSchema/actionTypes.js b/src/hooks/useSchema/actionTypes.js index 21acf6a..0a56959 100644 --- a/src/hooks/useSchema/actionTypes.js +++ b/src/hooks/useSchema/actionTypes.js @@ -2,3 +2,4 @@ export const ON_CHANGE = 'bi-diagram/useSchema/change'; export const ON_NODE_ADD = 'bi-diagram/useSchema/node/add'; export const ON_NODE_REMOVE = 'bi-diagram/useSchema/node/remove'; export const ON_CONNECT = 'bi-diagram/useSchema/connect'; +export const ON_DISCONNECT = 'bi-diagram/useSchema/disconnect'; diff --git a/src/hooks/useSchema/schemaReducer.js b/src/hooks/useSchema/schemaReducer.js index ac37e6a..6da06f3 100644 --- a/src/hooks/useSchema/schemaReducer.js +++ b/src/hooks/useSchema/schemaReducer.js @@ -1,5 +1,6 @@ import findIndex from 'lodash.findindex'; -import { ON_CHANGE, ON_CONNECT, ON_NODE_ADD, ON_NODE_REMOVE } from './actionTypes'; +import isEqual from 'lodash.isequal'; +import { ON_CHANGE, ON_CONNECT, ON_DISCONNECT, ON_NODE_ADD, ON_NODE_REMOVE } from './actionTypes'; import getNodePortsId from '../../shared/functions/getNodePortsId'; /** @@ -45,6 +46,11 @@ const schemaReducer = (state, action) => { nodes: state.nodes || [], links: state.links || [], }); + case ON_DISCONNECT: + return ({ + nodes: state.nodes || [], + links: state.links.filter((link) => !isEqual(link, action.payload.link)) || [], + }); default: return state; } diff --git a/src/hooks/useSchema/useSchema.js b/src/hooks/useSchema/useSchema.js index 76c7401..917e6d8 100644 --- a/src/hooks/useSchema/useSchema.js +++ b/src/hooks/useSchema/useSchema.js @@ -1,7 +1,7 @@ import { useReducer, useCallback } from 'react'; import ensureNodeId from '../../shared/functions/ensureNodeId'; import schemaReducer from './schemaReducer'; -import { ON_CHANGE, ON_CONNECT, ON_NODE_ADD, ON_NODE_REMOVE } from './actionTypes'; +import { ON_CHANGE, ON_CONNECT, ON_NODE_ADD, ON_NODE_REMOVE, ON_DISCONNECT } from './actionTypes'; const initialState = { nodes: [], links: [] }; @@ -17,8 +17,9 @@ const useSchema = (initialSchema = initialState) => { const addNode = useCallback((node) => dispatch({ type: ON_NODE_ADD, payload: { node: ensureNodeId(node) } }), []); const removeNode = useCallback((node) => dispatch({ type: ON_NODE_REMOVE, payload: { nodeId: node.id } }), []); const connect = useCallback((input, output) => dispatch({ type: ON_CONNECT, payload: { link: { input, output } } }), []); + const disconnect = useCallback((input, output) => dispatch({ type: ON_DISCONNECT, payload: { link: { input, output } } }), []); - return [schema, Object.freeze({ onChange, addNode, removeNode, connect })]; + return [schema, Object.freeze({ onChange, addNode, removeNode, connect, disconnect })]; }; /* eslint-enable max-len */ diff --git a/src/index.js b/src/index.js index 0bcc28d..3def994 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,10 @@ -import Diagram from './Diagram'; +import Diagram from './components/Diagram'; -export { default as Diagram } from './Diagram'; +export { default as Canvas } from './components/Canvas'; +export { default as Diagram } from './components/Diagram'; +export { default as CanvasControls } from './components/CanvasControls'; export { default as useSchema } from './hooks/useSchema'; +export { default as useCanvasState } from './hooks/useCanvasState'; export { default as createSchema } from './shared/functions/createSchema'; export { validateNode } from './shared/functions/validators'; export { validateNodes } from './shared/functions/validators'; diff --git a/src/shared/Constants.js b/src/shared/Constants.js new file mode 100644 index 0000000..8af066d --- /dev/null +++ b/src/shared/Constants.js @@ -0,0 +1,16 @@ +export const isTouch = 'ontouchstart' in window; + +export const noop = () => undefined; + +/** + * TODO: explain why on earth you'd do something like this + */ +export const Events = Object.freeze({ + MOUSE_START: isTouch ? 'touchstart' : 'mousedown', + MOUSE_MOVE: isTouch ? 'touchmove' : 'mousemove', + MOUSE_END: isTouch ? 'touchend' : 'mouseup', + DOUBLE_CLICK: 'dblclick', + WHEEL: 'wheel', +}); + +export const stopPropagation = (e) => e.stopPropagation(); diff --git a/src/shared/functions/pipe.js b/src/shared/functions/pipe.js new file mode 100644 index 0000000..8209382 --- /dev/null +++ b/src/shared/functions/pipe.js @@ -0,0 +1,3 @@ +const pipe = (...fns) => (x) => fns.reduce((y, f) => f(y), x); + +export default pipe; diff --git a/src/shared/internal_hooks/useCanvas.js b/src/shared/internal_hooks/useCanvas.js index 3e203d1..e64965b 100644 --- a/src/shared/internal_hooks/useCanvas.js +++ b/src/shared/internal_hooks/useCanvas.js @@ -5,9 +5,9 @@ import DiagramContext from '../../Context/DiagramContext'; * Returns the canvas bounding box from the DiagramContext */ const useCanvas = () => { - const { canvas } = useContext(DiagramContext); + const { canvas, panVal, scaleVal } = useContext(DiagramContext); - return canvas; + return { canvas, panVal, scaleVal }; }; export default useCanvas; diff --git a/tests/Diagram.spec.js b/tests/Diagram.spec.js index ea72916..1db8695 100644 --- a/tests/Diagram.spec.js +++ b/tests/Diagram.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; -import Diagram from '../dist/Diagram'; +import Diagram from '../dist/components/Diagram'; describe('Diagram component', () => { afterEach(cleanup); diff --git a/tests/DiagramCanvas.spec.js b/tests/DiagramCanvas.spec.js index 38409e2..1fc06f0 100644 --- a/tests/DiagramCanvas.spec.js +++ b/tests/DiagramCanvas.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; -import DiagramCanvas from '../dist/Diagram/DiagramCanvas/DiagramCanvas'; +import DiagramCanvas from '../dist/components/Diagram/DiagramCanvas/DiagramCanvas'; describe('DiagramCanvas component', () => { afterEach(cleanup); diff --git a/tests/DiagramNode.spec.js b/tests/DiagramNode.spec.js index 470bf12..6355e71 100644 --- a/tests/DiagramNode.spec.js +++ b/tests/DiagramNode.spec.js @@ -1,7 +1,7 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; import DiagramContext from '../dist/Context/DiagramContext'; -import DiagramNode from '../dist/Diagram/DiagramNode/DiagramNode'; +import DiagramNode from '../dist/components/Diagram/DiagramNode/DiagramNode'; describe('DiagramNode component', () => { afterEach(cleanup); diff --git a/tests/Link.spec.js b/tests/Link.spec.js index 0ebd19f..269bd0e 100644 --- a/tests/Link.spec.js +++ b/tests/Link.spec.js @@ -1,7 +1,7 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; import DiagramContext from '../dist/Context/DiagramContext'; -import DiagramLink from '../dist/Diagram/Link/Link'; +import DiagramLink from '../dist/components/Diagram/Link/Link'; describe('Link component', () => { afterEach(cleanup); diff --git a/tests/LinkLabel.spec.js b/tests/LinkLabel.spec.js index 03e656d..5026772 100644 --- a/tests/LinkLabel.spec.js +++ b/tests/LinkLabel.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; -import LinkLabel from '../dist/Diagram/Link/LinkLabel'; +import LinkLabel from '../dist/components/Diagram/Link/LinkLabel'; describe('LinkLabel component', () => { afterEach(cleanup); diff --git a/tests/NodesCanvas.spec.js b/tests/NodesCanvas.spec.js index 1c72d00..bb525c5 100644 --- a/tests/NodesCanvas.spec.js +++ b/tests/NodesCanvas.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; -import NodesCanvas from '../dist/Diagram/NodesCanvas/NodesCanvas'; +import NodesCanvas from '../dist/components/Diagram/NodesCanvas/NodesCanvas'; describe('NodesCanvas component', () => { afterEach(cleanup); diff --git a/tests/Port.spec.js b/tests/Port.spec.js index 3f7ba67..4321862 100644 --- a/tests/Port.spec.js +++ b/tests/Port.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; -import Port from '../dist/Diagram/Port/Port'; +import Port from '../dist/components/Diagram/Port/Port'; describe('Port component', () => { afterEach(cleanup); diff --git a/tests/Segment.spec.js b/tests/Segment.spec.js index bdbbb6b..0a25f40 100644 --- a/tests/Segment.spec.js +++ b/tests/Segment.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; -import Segment from '../dist/Diagram/Segment/Segment'; +import Segment from '../dist/components/Diagram/Segment/Segment'; describe('Segment component', () => { afterEach(cleanup); diff --git a/tests/findInvolvedEntity.spec.js b/tests/findInvolvedEntity.spec.js index 6bc3d59..ff8a3c9 100644 --- a/tests/findInvolvedEntity.spec.js +++ b/tests/findInvolvedEntity.spec.js @@ -1,4 +1,4 @@ -import findInvolvedEntity from '../dist/Diagram/LinksCanvas/findInvolvedEntity'; +import findInvolvedEntity from '../dist/components/Diagram/LinksCanvas/findInvolvedEntity'; describe('findInvolvedEntity utility function', () => { it('should be a function', () => { diff --git a/tests/getEntityCoordinates.spec.js b/tests/getEntityCoordinates.spec.js index f80900a..0dfe835 100644 --- a/tests/getEntityCoordinates.spec.js +++ b/tests/getEntityCoordinates.spec.js @@ -1,4 +1,4 @@ -import getEntityCoordinates from '../dist/Diagram/Link/getEntityCoordinates'; +import getEntityCoordinates from '../dist/components/Diagram/Link/getEntityCoordinates'; describe('getEntityCoordinates function', () => { const portRefs = { 'port-foo': document.createElement('div') }; diff --git a/tests/removeLinkFromArray.spec.js b/tests/removeLinkFromArray.spec.js index 050be67..7a3aac8 100644 --- a/tests/removeLinkFromArray.spec.js +++ b/tests/removeLinkFromArray.spec.js @@ -1,4 +1,4 @@ -import removeLinkFromArray from '../dist/Diagram/LinksCanvas/removeLinkFromArray'; +import removeLinkFromArray from '../dist/components/Diagram/LinksCanvas/removeLinkFromArray'; describe('removeLinkFromArray utility function', () => { it('should be a function', () => { diff --git a/tests/updateNodeCoordinates.spec.js b/tests/updateNodeCoordinates.spec.js index bd268c5..59bca76 100644 --- a/tests/updateNodeCoordinates.spec.js +++ b/tests/updateNodeCoordinates.spec.js @@ -1,4 +1,4 @@ -import updateNodeCoordinates from '../dist/Diagram/NodesCanvas/updateNodeCoordinates'; +import updateNodeCoordinates from '../dist/components/Diagram/NodesCanvas/updateNodeCoordinates'; describe('updateNodeCoordinates utility function', () => { it('should be a function', () => { diff --git a/tests/useCanvas.spec.js b/tests/useCanvas.spec.js index 383a3e3..ba9d832 100644 --- a/tests/useCanvas.spec.js +++ b/tests/useCanvas.spec.js @@ -1,7 +1,7 @@ import useCanvas from '../dist/shared/internal_hooks/useCanvas'; // TODO: test this hook -describe('useCanvas hook', () => { +describe('useCanvasState hook', () => { it('should be a function', () => { expect(useCanvas).to.be.a('function'); }); diff --git a/tests/useSchema.spec.js b/tests/useSchema.spec.js index 7f44069..6c292d0 100644 --- a/tests/useSchema.spec.js +++ b/tests/useSchema.spec.js @@ -1,7 +1,7 @@ import useSchema from '../dist/hooks/useSchema'; import schemaReducer from '../dist/hooks/useSchema/schemaReducer'; import createSchema from '../dist/shared/functions/createSchema'; -import { ON_CHANGE, ON_CONNECT, ON_NODE_ADD, ON_NODE_REMOVE } from '../dist/hooks/useSchema/actionTypes'; +import { ON_CHANGE, ON_CONNECT, ON_NODE_ADD, ON_NODE_REMOVE, ON_DISCONNECT } from '../dist/hooks/useSchema/actionTypes'; describe('useSchema reducer', () => { it('should return the same state if the action is invalid', () => { @@ -96,6 +96,40 @@ describe('useSchema reducer', () => { expect(nextSchema.nodes).to.deep.equal([]); expect(nextSchema.links).to.deep.equal([link]); }); + + it('should remove the link between two nodes ON_DISCONNECT action', () => { + const schema = createSchema({ + nodes: [ + { + id: 'node-1', + content: 'Node 1', + coordinates: [1, 0], + outputs: [{ id: 'port-1' }], + }, + { + id: 'node-2', + content: 'Node 2', + coordinates: [2, 0], + inputs: [{ id: 'port-2' }], + }, + { + id: 'node-3', + content: 'Node 3', + coordinates: [3, 0], + inputs: [{ id: 'port-3' }], + }, + ], + links: [{ input: 'port-2', output: 'port-1' }, { input: 'port-3', output: 'port-1' }], + }); + const link = { input: 'port-3', output: 'port-1' }; + + const nextSchema = schemaReducer(schema, { type: ON_DISCONNECT, payload: { link } }); + + expect(nextSchema).to.have.property('nodes'); + expect(nextSchema).to.have.property('links'); + expect(nextSchema.nodes).to.deep.equal(schema.nodes); + expect(nextSchema.links).to.deep.equal([{ input: 'port-2', output: 'port-1' }]); + }); }); // TODO: test this hook