diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..4f06b0c --- /dev/null +++ b/.babelrc @@ -0,0 +1,6 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-react" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcace55..325c159 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Prepare distribution - run: cp -r ./@types ./dist/@types && cp package.json index.d.ts README.md LICENSE CHANGELOG.md CONTRIBUTING.md CODE_OF_CONDUCT.md ./dist + run: cp -r ./@types ./dist/@types && cp package.json index.d.ts CanvasControls.md LICENSE CHANGELOG.md CONTRIBUTING.md CODE_OF_CONDUCT.md ./dist - name: Publish run: | 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 7535f8e..83c3e86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,3 +134,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - First implementation of draggable canvas - First implementation of zoomable canvas + +## [0.6.0] - 2020-11-24 + +### 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/drag&zoom.md b/docs/drag&zoom.md deleted file mode 100644 index 7c1f8a7..0000000 --- a/docs/drag&zoom.md +++ /dev/null @@ -1,73 +0,0 @@ -It is possible to create big diagram and navigate into it using `draggable` prop. -This prop will let you to move the diagram canvas in every direction till its limits. - -``` jsx -import Diagram, { createSchema, useSchema } from 'beautiful-react-diagrams'; - -// the diagram model -const initialSchema = createSchema({ - nodes: [ - { id: 'node-1', content: 'Node 1', coordinates: [2450, 2350], }, - { id: 'node-2', content: 'Node 2', coordinates: [2200, 2500], }, - { id: 'node-3', content: 'Node 3', coordinates:[2450, 2500], }, - { id: 'node-4', content: 'Node 4', coordinates: [2700, 2500], }, - ], - 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 ( -
- -
- ); -}; - - -``` - -It is possible to zoom into the diagram using two props: -- `showZoomButtons`: that will let you zoom into the diagram using buttons. -- `zoomOnWheel`: that will let you zoom into the diagram using the mouse wheel. -It is possible to change the zoom button position using `zoomButtonsPosition` that accepts one of the following values: -`top-left`, `top-right`, `top-center`, `bottom-right`, `bottom-center`, `bottom-left`. -The default value for `zoomButtonsPosition` is `bottom-right`. - -``` jsx -import Diagram, { createSchema, useSchema } from 'beautiful-react-diagrams'; - -// the diagram model -const initialSchema = createSchema({ - nodes: [ - { id: 'node-1', content: 'Node 1', coordinates: [2450, 2350], }, - { id: 'node-2', content: 'Node 2', coordinates: [2200, 2500], }, - { id: 'node-3', content: 'Node 3', coordinates:[2450, 2500], }, - { id: 'node-4', content: 'Node 4', coordinates: [2700, 2500], }, - ], - 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/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 1cd1724..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,50 +29,48 @@ module.exports = { sectionDepth: 1, }, { - name: 'Canvas', - content: '../../src/components/Canvas/README.md', + name: 'Basic Usage', + content: '../Basic-usage.md', sectionDepth: 1, }, { - name: 'Diagram Component', - content: '../../src/components/Diagram/README.md', + name: 'Links and Ports', + content: '../Links-Ports.md', sectionDepth: 1, }, { - name: 'Schema', - content: '../schema.md', + name: 'Canvas Controls', + content: '../CanvasControls.md', sectionDepth: 1, }, { - name: 'Linking nodes', - content: '../links.md', + name: 'Customisation', + content: '../customisation.md', sectionDepth: 1, }, { - name: 'Ports', - content: '../ports.md', + name: 'Dynamic nodes', + content: '../dynamic-nodes.md', sectionDepth: 1, }, { - name: 'Customisation', - content: '../customisation.md', + name: 'Schema utilities', + content: '../Schema-utils.md', sectionDepth: 1, }, - - { divider: true }, { - name: 'Dynamic nodes', - content: '../dynamic-nodes.md', + name: 'useSchema', + content: '../useSchema.md', sectionDepth: 1, }, { - name: 'Drag & Zoom', - content: '../drag&zoom.md', + name: 'useCanvasState', + content: '../useCanvasState.md', sectionDepth: 1, }, { - name: 'Hooks', - content: '../hooks.md', + 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/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 f8b8de9..34fe2d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "beautiful-react-diagrams", - "version": "0.5.0", + "version": "0.6.0", "description": "A tiny collection of lightweight React components to build diagrams with ease", "main": "index.js", "module": "esm/index.js", @@ -90,7 +90,6 @@ "beautiful-react-hooks": "^0.31.0", "classnames": "^2.2.6", "lodash.findindex": "^4.6.0", - "lodash.inrange": "3.3.6", "lodash.isequal": "^4.5.0", "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2" diff --git a/src/components/Canvas/BackgroundGrid.js b/src/components/Canvas/BackgroundGrid.js index 1b263f5..aaa6ae2 100644 --- a/src/components/Canvas/BackgroundGrid.js +++ b/src/components/Canvas/BackgroundGrid.js @@ -6,7 +6,7 @@ const calcCoordinates = (x, y) => ([x * parallaxRatio, y * parallaxRatio]); const calcTransformation = (x, y, scale) => (`scale(${scale}) translate(${x}, ${y})`); /** - * Canvas background + * TODO: document me */ const BackgroundGrid = ({ translateX, translateY, scale, svgPatternColor, svgPatternOpacity }) => { const [x, y] = useMemo(() => calcCoordinates(translateX, translateY), [translateX, translateY]); @@ -23,7 +23,6 @@ const BackgroundGrid = ({ translateX, translateY, scale, svgPatternColor, svgPat - diff --git a/src/components/Canvas/Canvas.js b/src/components/Canvas/Canvas.js index bab84a6..8202878 100644 --- a/src/components/Canvas/Canvas.js +++ b/src/components/Canvas/Canvas.js @@ -1,13 +1,15 @@ import React, { useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import useCanvasPan from './useCanvasPan'; -import useCanvasZoom from './useCanvasZoom'; +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 makeStyle = (scale = 1, { x = 0, y = 0 }) => ({ +const calcTransformation = (scale = 1, { x = 0, y = 0 }) => ({ transform: `translate(${x}px, ${y}px) translateZ(0) scale(${scale})`, }); @@ -16,51 +18,87 @@ const makeStyle = (scale = 1, { x = 0, y = 0 }) => ({ */ const Canvas = (props) => { const { - initialZoom, maxZoom, minZoom, zoomable, pannable, zoomOnWheel, inertia, debug, children, className, - ElementRenderer, GridRenderer, ...rest + pan, onPanChange, zoom, onZoomChange, maxZoom, minZoom, zoomOnWheel, inertia, zoomResetOnDblClick, + ElementRenderer, GridRenderer, debug, className, children, ...rest } = props; const elRef = useRef(); - const [pan, startPan] = useCanvasPan({ pannable, inertia }); - const [scale] = useCanvasZoom(elRef, { initialZoom, maxZoom, minZoom, zoomable, zoomOnWheel }); const classList = useMemo(() => classNames('bi bi-diagram bi-diagram-canvas', className), [className]); - const style = useMemo(() => makeStyle(scale, pan), [scale, pan.x, pan.y]); + 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 ( - +

- {children} + {filterControlsOut(children)}
{debug && (
-

{JSON.stringify(pan)}

-

{`Scale: ${scale}`}

+

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

+

{`Scale: ${zoom}`}

)} + {enrichControls(children, { onPanChange, onZoomChange, minZoom, maxZoom })} ); }; Canvas.propTypes = { - zoomable: PropTypes.bool, - pannable: PropTypes.bool, - initialZoom: PropTypes.number, + /** + * 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 = { - zoomable: true, - pannable: true, - initialZoom: 1, + pan: { x: 0, y: 0 }, + onPanChange: noop, + zoom: 1, + onZoomChange: noop, zoomOnWheel: true, - maxZoom: 5, - minZoom: 0.4, + maxZoom: 2, + minZoom: 0.5, + zoomResetOnDblClick: true, inertia: true, debug: false, GridRenderer: BackgroundGrid, diff --git a/src/components/Canvas/README.md b/src/components/Canvas/README.md deleted file mode 100644 index ce3845a..0000000 --- a/src/components/Canvas/README.md +++ /dev/null @@ -1,9 +0,0 @@ -``` -import { Canvas } from 'beautiful-react-diagrams'; - -
- -

Element

-
-
-``` diff --git a/src/components/Canvas/childrenUtils.js b/src/components/Canvas/childrenUtils.js new file mode 100644 index 0000000..02fe4bc --- /dev/null +++ b/src/components/Canvas/childrenUtils.js @@ -0,0 +1,18 @@ +import { Children, cloneElement } from 'react'; +import CanvasControls from '../CanvasControls'; + +// todo: document this method +export const filterControlsOut = (children) => Children.map(children, (C) => (C.type !== CanvasControls ? C : 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/useCanvasPan.js b/src/components/Canvas/useCanvasPanHandlers.js similarity index 69% rename from src/components/Canvas/useCanvasPan.js rename to src/components/Canvas/useCanvasPanHandlers.js index fedb0a9..79e0756 100644 --- a/src/components/Canvas/useCanvasPan.js +++ b/src/components/Canvas/useCanvasPanHandlers.js @@ -1,33 +1,30 @@ -import { useState, useCallback, useRef } from 'react'; -import Events from '../../shared/Events'; -import { isTouch } from '../../shared/Constants'; +import { useCallback, useRef } from 'react'; +import { Events, isTouch } from '../../shared/Constants'; -const initialState = { x: 0, y: 0 }; 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 getDelta = (point, lastPoint) => ({ x: lastPoint.x - point.x, y: lastPoint.y - point.y }); +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-pannable--zoomable-canvasdi + * https://jclem.net/posts/pan-zoom-canvas-react?utm_campaign=building-a-s--zoomable-canvasdi */ -const useCanvasPan = ({ pannable, inertia }) => { - const [pan, setPan] = useState(initialState); - const lastPointRef = useRef(initialState); +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 (pannable) { + if (onPanChange) { const lastPoint = { ...lastPointRef.current }; const point = getEventPoint(event); lastPointRef.current = point; - setPan(({ x, y }) => { - const delta = getDelta(lastPoint, point); + onPanChange(({ x, y }) => { + const delta = calculateDelta(lastPoint, point); deltaRef.current = { ...delta }; return { x: x + delta.x, y: y + delta.y }; @@ -38,7 +35,7 @@ const useCanvasPan = ({ pannable, inertia }) => { // TODO: document this callback const performInertia = useCallback(() => { if (inertia) { - setPan(({ x, y }) => ({ x: x + deltaRef.current.x, y: y + deltaRef.current.y })); + 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); @@ -51,7 +48,7 @@ const useCanvasPan = ({ pannable, inertia }) => { // TODO: document this callback const endPan = useCallback(() => { - if (pannable) { + if (onPanChange) { document.removeEventListener(Events.MOUSE_MOVE, performPan); document.removeEventListener(Events.MOUSE_END, endPan); @@ -59,18 +56,18 @@ const useCanvasPan = ({ pannable, inertia }) => { requestAnimationFrame(performInertia); } } - }, [performPan]); + }, [performPan, inertia, onPanChange]); // TODO: document this callback const onPanStart = useCallback((event) => { - if (pannable) { + if (onPanChange) { document.addEventListener(Events.MOUSE_MOVE, performPan); document.addEventListener(Events.MOUSE_END, endPan); lastPointRef.current = getEventPoint(event); } - }, [performPan, endPan]); + }, [onPanChange, performPan, endPan]); - return [pan, onPanStart]; + return onPanStart; }; -export default useCanvasPan; +export default useCanvasPanHandlers; diff --git a/src/components/Canvas/useCanvasZoom.js b/src/components/Canvas/useCanvasZoomHandlers.js similarity index 53% rename from src/components/Canvas/useCanvasZoom.js rename to src/components/Canvas/useCanvasZoomHandlers.js index b9360c1..afe8c21 100644 --- a/src/components/Canvas/useCanvasZoom.js +++ b/src/components/Canvas/useCanvasZoomHandlers.js @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; -import Events from '../../shared/Events'; +import { useEffect, useCallback } from 'react'; +import { Events } from '../../shared/Constants'; // TODO: move to the hooks library const useEvent = (ref, event, callback, options) => { @@ -16,23 +16,21 @@ const useEvent = (ref, event, callback, options) => { }, [ref.current]); }; -const defaultOptions = { initialZoom: 1, maxZoom: 5, minZoom: 0.4 }; +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 useCanvasZoom = (ref, options = defaultOptions) => { - const { initialZoom, maxZoom, minZoom, zoomable, zoomOnWheel } = options; - const [scale, setScale] = useState(initialZoom); +const useCanvasZoomHandlers = (ref, options = defaultOptions) => { + const { onZoomChange, maxZoom, minZoom, zoomOnWheel, zoomResetOnDblClick } = options; const scaleOnWheel = useCallback((event) => { - if (zoomable && zoomOnWheel) { - event.preventDefault(); // FIXME: double check on event bubbling + if (onZoomChange && zoomOnWheel) { + event.preventDefault(); // FIXME: double check the bubbling of this event you know nothing about - setScale((currentScale) => { - // Adjust up to or down to the maximum or minimum scale levels by `interval`. + onZoomChange((currentScale) => { if (event.deltaY > 0) { return (currentScale + wheelOffset < maxZoom) ? (currentScale + wheelOffset) : maxZoom; } @@ -40,11 +38,17 @@ const useCanvasZoom = (ref, options = defaultOptions) => { return (currentScale - wheelOffset > minZoom) ? (currentScale - wheelOffset) : minZoom; }); } - }, [zoomable, setScale, maxZoom, minZoom]); + }, [onZoomChange, maxZoom, minZoom]); - useEvent(ref, Events.WHEEL, scaleOnWheel, { passive: false }); + const resetZoom = useCallback((event) => { + if (onZoomChange && zoomResetOnDblClick) { + event.preventDefault(); + onZoomChange(1); + } + }, []); - return [scale, setScale]; + useEvent(ref, Events.WHEEL, scaleOnWheel, { passive: false }); + useEvent(ref, Events.DOUBLE_CLICK, resetZoom, { passive: false }); }; -export default useCanvasZoom; +export default useCanvasZoomHandlers; diff --git a/src/components/CanvasControls/CanvasControls.js b/src/components/CanvasControls/CanvasControls.js new file mode 100644 index 0000000..bf8cd8c --- /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, stopPropagation } 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/components/Diagram/DiagramCanvas/DiagramCanvas.js b/src/components/Diagram/DiagramCanvas/DiagramCanvas.js index 05f0115..9e0444e 100644 --- a/src/components/Diagram/DiagramCanvas/DiagramCanvas.js +++ b/src/components/Diagram/DiagramCanvas/DiagramCanvas.js @@ -1,12 +1,20 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useWindowScroll, useWindowResize, useMouseEvents } from 'beautiful-react-hooks'; +import React, { useEffect, useRef, useState } from 'react'; +import { useWindowScroll, useWindowResize } from 'beautiful-react-hooks'; import isEqual from 'lodash.isequal'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import DiagramContext from '../../../Context/DiagramContext'; -import getDiagramCanvasCoords from './utils/getDiagramCanvasCoords'; -import getCanvasDragLimits from './utils/getCanvasDragLimits'; -import DiagramZoomButtons from '../DiagramZoomButtons/DiagramZoomButtons'; + +const extrapolateValues = (bbox) => ({ + bottom: Math.round(bbox.bottom), + height: Math.round(bbox.height), + left: Math.round(bbox.left), + right: Math.round(bbox.right), + top: Math.round(bbox.top), + width: Math.round(bbox.width), + x: Math.round(bbox.x), + y: Math.round(bbox.y), +}); /** * The DiagramCanvas component provides a context to the Diagram children. @@ -14,154 +22,37 @@ import DiagramZoomButtons from '../DiagramZoomButtons/DiagramZoomButtons'; * allow links to easily access to a the ports coordinates */ const DiagramCanvas = (props) => { - const { - children, portRefs, nodeRefs, draggable, delta, zoomButtonsPosition, showZoomButtons, - maxZoom, minZoom, zoomOnWheel, className, style, ...rest - } = props; - const [bbox, setBoundingBox] = useState(null); + const { children, portRefs, nodeRefs, className, ...rest } = props; + const [bbox, setBoundingBox] = useState(); const canvasRef = useRef(); - const mouseCoords = useRef(); - - const [isDragging, setIsDragging] = useState(false); - const { onMouseDown, onMouseMove, onMouseUp, onMouseLeave } = useMouseEvents(canvasRef); - const [canvasTranslate, setCanvasTranslate] = useState([0, 0]); - const [canvasScale, setCanvasScale] = useState(1); - const classList = classNames('bi bi-diagram-canvas', { - 'enlarge-diagram-canvas': draggable || showZoomButtons || zoomOnWheel, - }, className); - - const wrapperClassList = classNames('bi bi-diagram', { - isPanning: isDragging, - pannable: draggable || showZoomButtons || zoomOnWheel, - }); + 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 = extrapolateValues(canvasRef.current.getBoundingClientRect()); if (!isEqual(nextBBox, bbox)) { setBoundingBox(nextBBox); } } }; - // when the canvas is ready and placed within the DOM, update canvasRef coordinates - // and save its bounding box to be provided down to children component as a context value for future calculations. - useEffect(() => { - calculateBBox(canvasRef.current); - if (draggable || showZoomButtons || zoomOnWheel) { - const canvasBBox = canvasRef.current.getBoundingClientRect(); - setCanvasTranslate([-(canvasBBox.width / 2), -(canvasBBox.height / 2)]); - } - }, [canvasRef.current]); + // 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]); // same on window scroll and resize - useWindowScroll(() => calculateBBox(canvasRef.current)); - useWindowResize(() => calculateBBox(canvasRef.current)); - - // save mouse coordinated if diagram is draggable - onMouseDown((event) => { - if (draggable) { - mouseCoords.current = [event.pageX, event.pageY]; - setIsDragging(true); - } - }); + useWindowScroll(calculateBBox); + useWindowResize(calculateBBox); - /** - * on mouse move update diagram canvas coordinates and save the latest mouse coordinates - */ - onMouseMove((event) => { - if (draggable && isDragging) { - const currentMouseCoords = [event.pageX, event.pageY]; - const deltaXMouse = currentMouseCoords[0] - mouseCoords.current[0]; - const deltaYMouse = currentMouseCoords[1] - mouseCoords.current[1]; - const canvasParent = canvasRef.current.parentElement; - const canvasParentDim = [canvasParent.offsetWidth, canvasParent.offsetHeight]; - const canvasDim = [canvasRef.current.offsetWidth, canvasRef.current.offsetHeight]; - // get the canvas drag limit - const [topLimit, rightLimit, bottomLimit, leftLimit] = getCanvasDragLimits(canvasDim, canvasParentDim); - // start dragging only if the mouse movement is bigger than the delta prop - // drag the canvas till its limits - if (deltaXMouse > delta && canvasTranslate[0] <= leftLimit) { - setCanvasTranslate([canvasTranslate[0] + deltaXMouse, canvasTranslate[1]]); - mouseCoords.current = [currentMouseCoords[0], mouseCoords.current[1]]; - } - if (deltaXMouse < -delta && canvasTranslate[0] >= rightLimit) { - setCanvasTranslate([canvasTranslate[0] + deltaXMouse, canvasTranslate[1]]); - mouseCoords.current = [currentMouseCoords[0], mouseCoords.current[1]]; - } - if (deltaYMouse > delta && canvasTranslate[1] <= topLimit) { - setCanvasTranslate([canvasTranslate[0], canvasTranslate[1] + deltaYMouse]); - mouseCoords.current = [mouseCoords.current[0], currentMouseCoords[1]]; - } - if (deltaYMouse < -delta && canvasTranslate[1] >= bottomLimit) { - setCanvasTranslate([canvasTranslate[0], canvasTranslate[1] + deltaYMouse]); - mouseCoords.current = [mouseCoords.current[0], currentMouseCoords[1]]; - } - } + // FIXME: horrible hack + useEffect(() => { + setTimeout(calculateBBox, 120); }); - const stopDragging = useCallback(() => { - if (draggable) { - setIsDragging(false); - mouseCoords.current = []; - } - }, [draggable, setIsDragging]); - - onMouseUp(stopDragging); - onMouseLeave(stopDragging); - - const zoomInHandler = useCallback(() => { - if (canvasScale <= maxZoom) { - setCanvasScale(canvasScale + 0.1); - } - }, [canvasScale, setCanvasScale, maxZoom]); - - const resetZoomHandler = useCallback(() => { - setCanvasScale(1); - }, [setCanvasScale]); - - const zoomOutHandler = useCallback(() => { - if (canvasScale > minZoom) { - setCanvasScale(canvasScale - 0.1); - } - }, [canvasScale, setCanvasScale, minZoom]); - - const zoomOnWheelHandler = useCallback((event) => { - event.preventDefault(); - if (event.deltaY > 0) { - zoomInHandler(); - } else { - zoomOutHandler(); - } - }, [zoomOutHandler, zoomInHandler]); - - const getDiagramStyle = useCallback(() => { - if (draggable || showZoomButtons || zoomOnWheel) { - return { ...style, ...getDiagramCanvasCoords(canvasTranslate[0], canvasTranslate[1], canvasScale) }; - } - return { ...style }; - }, [draggable, showZoomButtons, zoomOnWheel, canvasTranslate[0], canvasTranslate[1], canvasScale]); - return ( -
- {(showZoomButtons) && ( - = maxZoom} - buttonsPosition={zoomButtonsPosition} - /> - )} -
- +
+
+ {children}
@@ -172,40 +63,13 @@ const DiagramCanvas = (props) => { DiagramCanvas.propTypes = { portRefs: PropTypes.shape({}), nodeRefs: PropTypes.shape({}), - /** - * Defines if the user can move the diagram canvas to reach every node - */ - draggable: PropTypes.bool, - /** - * Defines how many pixels the mouse should drag before starting the canvas panning - */ - delta: PropTypes.number, - /** - * Enable the zoom on diagram canvas and show zoom buttons - */ - showZoomButtons: PropTypes.bool, - /** - * Enable zoom on canvas by mouse wheel - */ - zoomOnWheel: PropTypes.bool, - // eslint-disable-next-line max-len - zoomButtonsPosition: PropTypes.oneOf(['top-left', 'top-right', 'top-center', 'bottom-right', 'bottom-center', 'bottom-left']), className: PropTypes.string, - minZoom: PropTypes.number, - maxZoom: PropTypes.number, }; DiagramCanvas.defaultProps = { portRefs: {}, nodeRefs: {}, className: '', - draggable: false, - delta: 5, - showZoomButtons: false, - zoomOnWheel: false, - zoomButtonsPosition: 'bottom-right', - minZoom: 1, - maxZoom: 100, }; export default React.memo(DiagramCanvas); diff --git a/src/components/Diagram/DiagramCanvas/utils/getCanvasDragLimits.js b/src/components/Diagram/DiagramCanvas/utils/getCanvasDragLimits.js deleted file mode 100644 index 2e23c84..0000000 --- a/src/components/Diagram/DiagramCanvas/utils/getCanvasDragLimits.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Given the canvas and its parent dimensions, returns the canvas drag limits - * @param canvasDim - * @param canvasParentDim - * @returns {[number, number, number, number]} - */ -const getCanvasDragLimits = (canvasDim, canvasParentDim) => { - const [canvasWidth, canvasHeight] = Array.isArray(canvasDim) ? canvasDim : [0, 0]; - const [parentWidth, parentHeight] = Array.isArray(canvasParentDim) ? canvasParentDim : [0, 0]; - const topLimit = parentHeight > 0 ? -(parentHeight / 2) : 0; - const rightLimit = canvasWidth > 0 && parentWidth > 0 ? -canvasWidth + (parentWidth / 2) : 0; - const bottomLimit = canvasHeight > 0 && parentHeight > 0 ? -canvasHeight + (parentHeight / 2) : 0; - const leftLimit = parentWidth > 0 ? -(parentWidth / 2) : 0; - - return [topLimit, rightLimit, bottomLimit, leftLimit]; -}; - -export default getCanvasDragLimits; diff --git a/src/components/Diagram/DiagramCanvas/utils/getDiagramCanvasCoords.js b/src/components/Diagram/DiagramCanvas/utils/getDiagramCanvasCoords.js deleted file mode 100644 index 648fd71..0000000 --- a/src/components/Diagram/DiagramCanvas/utils/getDiagramCanvasCoords.js +++ /dev/null @@ -1,12 +0,0 @@ -// Returns an object with the transform style for the diagram canvas - -const getDiagramCanvasCoords = (left, top, scaleValue) => { - const newLeft = left || 0; - const newTop = top || 0; - const newScaleValue = scaleValue || 1; - return ({ - transform: `translate(${newLeft}px, ${newTop}px) scale(${newScaleValue})`, - }); -}; - -export default getDiagramCanvasCoords; diff --git a/src/components/Diagram/DiagramNode/DiagramNode.js b/src/components/Diagram/DiagramNode/DiagramNode.js index aa92b1f..c79aba5 100644 --- a/src/components/Diagram/DiagramNode/DiagramNode.js +++ b/src/components/Diagram/DiagramNode/DiagramNode.js @@ -1,7 +1,6 @@ import React, { useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import inRange from 'lodash.inrange'; import getDiagramNodeStyle from './getDiagramNodeStyle'; import { usePortRegistration, useNodeRegistration } from '../../../shared/internal_hooks/useContextRegistration'; import { PortType } from '../../../shared/Types'; @@ -17,35 +16,29 @@ import useNodeUnregistration from '../../../shared/internal_hooks/useNodeUnregis const DiagramNode = (props) => { const { id, content, coordinates, type, inputs, outputs, data, onPositionChange, onPortRegister, onNodeRemove, - onDragNewSegment, onMount, onSegmentFail, onSegmentConnect, render, className, disableDrag, + onDragNewSegment, onMount, onSegmentFail, onSegmentConnect, render, className, } = props; const registerPort = usePortRegistration(inputs, outputs, onPortRegister); // get the port registration method const { ref, onDragStart, onDrag } = useDrag({ throttleBy: 14 }); // get the drag n drop methods const dragStartPoint = useRef(coordinates); // keeps the drag start point in a persistent reference - if (!disableDrag) { - // when drag starts, save the starting coordinates into the `dragStartPoint` ref - onDragStart((event) => { - dragStartPoint.current = coordinates; - event.stopPropagation(); - }); + // when drag starts, save the starting coordinates into the `dragStartPoint` ref + onDragStart((event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + event.stopPropagation(); + dragStartPoint.current = coordinates; + }); - // whilst dragging calculates the next coordinates and perform the `onPositionChange` callback - onDrag((event, info) => { - if (onPositionChange) { - event.stopImmediatePropagation(); - event.stopPropagation(); - const nextWidth = dragStartPoint.current[0] - info.offset[0]; - const nextHeight = dragStartPoint.current[1] - info.offset[1]; - const parentDim = [ref.current.parentElement.offsetWidth, ref.current.parentElement.offsetHeight]; - const refDim = [ref.current.offsetWidth, ref.current.offsetHeight]; - if (inRange(nextWidth, 0, parentDim[0] - refDim[0]) && inRange(nextHeight, 0, parentDim[1] - refDim[1])) { - const nextCoords = [nextWidth, nextHeight]; - onPositionChange(id, nextCoords); - } - } - }); - } + // whilst dragging calculates the next coordinates and perform the `onPositionChange` callback + onDrag((event, info) => { + if (onPositionChange) { + event.stopImmediatePropagation(); + event.stopPropagation(); + const nextCoords = [dragStartPoint.current[0] - info.offset[0], dragStartPoint.current[1] - info.offset[1]]; + onPositionChange(id, nextCoords); + } + }); // on component unmount, remove its references useNodeUnregistration(onNodeRemove, inputs, outputs, id); @@ -64,7 +57,7 @@ const DiagramNode = (props) => { const customRenderProps = { id, render, content, type, inputs: InputPorts, outputs: OutputPorts, data, className }; return ( -
+
{render && typeof render === 'function' && render(customRenderProps)} {!render && ( <> @@ -148,7 +141,6 @@ DiagramNode.propTypes = { * The possible className */ className: PropTypes.string, - disableDrag: PropTypes.bool, }; DiagramNode.defaultProps = { @@ -166,7 +158,6 @@ DiagramNode.defaultProps = { onSegmentFail: undefined, onSegmentConnect: undefined, className: '', - disableDrag: false, }; export default React.memo(DiagramNode); diff --git a/src/components/Diagram/DiagramZoomButtons/DiagramZoomButtons.js b/src/components/Diagram/DiagramZoomButtons/DiagramZoomButtons.js deleted file mode 100644 index 17d8205..0000000 --- a/src/components/Diagram/DiagramZoomButtons/DiagramZoomButtons.js +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useRef, useCallback } from 'react'; -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import getDiagramZoomButtonsPosition from './getDiagramZoomButtonsPosition'; - -// style -import './diagram-button.scss'; - -const DiagramZoomButtons = (props) => { - const { - onZoomIn, onResetZoom, onZoomOut, disableZoomInBtn, disableZoomOutBtn, buttonsPosition, className, ...rest - } = props; - const classList = classNames('diagram-zoom-buttons', { - 'vertical-orientation': !buttonsPosition.includes('center'), - }, className); - const buttonsGroupRef = useRef(); - - const calculateButtonsPosition = useCallback((position) => { - const parentElement = buttonsGroupRef.current && buttonsGroupRef.current.parentElement; - const parentDim = parentElement && [parentElement.offsetWidth, parentElement.offsetHeight]; - // eslint-disable-next-line max-len - const buttonsDim = buttonsGroupRef.current && [buttonsGroupRef.current.offsetWidth, buttonsGroupRef.current.offsetHeight]; - return getDiagramZoomButtonsPosition(buttonsDim, parentDim, position); - }, [buttonsGroupRef.current]); - - return ( -
-
- ); -}; - -DiagramZoomButtons.propTypes = { - /** - * A function to be perform on zoomIn button click - */ - onZoomIn: PropTypes.func.isRequired, - /** - * A function to be perform on zoom reset button click - */ - onResetZoom: PropTypes.func.isRequired, - /** - * A function to be perform on zoom out button click - */ - onZoomOut: PropTypes.func.isRequired, - /** - * Boolean value used to disabled or not the zoom buttons - */ - disableZoomOutBtn: PropTypes.bool, - /** - * Boolean value used to disabled or not the zoom in button - */ - disableZoomInBtn: PropTypes.bool, - // eslint-disable-next-line max-len - buttonsPosition: PropTypes.oneOf(['top-left', 'top-right', 'top-center', 'bottom-right', 'bottom-center', 'bottom-left']), - -}; - -DiagramZoomButtons.defaultProps = { - disableZoomOutBtn: false, - disableZoomInBtn: false, - buttonsPosition: 'bottom-right', -}; - -export default DiagramZoomButtons; diff --git a/src/components/Diagram/DiagramZoomButtons/diagram-button.scss b/src/components/Diagram/DiagramZoomButtons/diagram-button.scss deleted file mode 100644 index 450ce14..0000000 --- a/src/components/Diagram/DiagramZoomButtons/diagram-button.scss +++ /dev/null @@ -1,67 +0,0 @@ -.diagram-zoom-buttons { - display: inline-flex; - position: absolute; - z-index: 9999; - flex-direction: row; - border: 0.05rem solid; - border-radius: 0.5rem; - - > button { - padding: 1rem; - border: none; - background-repeat: no-repeat; - background-position: center; - background-size: 1.7rem; - } - - .zoom-in-btn { - background-image: url('icons/zoomIn.png'); - border-top-left-radius: 0.5rem; - border-bottom-left-radius: 0.5rem; - border-top-right-radius: 0; - - &.disabled { - opacity: 0.5; - } - } - - .zoom-out-btn { - background-image: url('icons/zoomOut.png'); - border-top-right-radius: 0.5rem; - border-bottom-right-radius: 0.5rem; - border-bottom-left-radius: 0; - - &.disabled { - opacity: 0.5; - } - } - - .zoom-reset-btn { - background-image: url('icons/zoomReset.png'); - border-left: 0.05rem solid lightgray; - border-right: 0.05rem solid lightgray; - - &.disabled { - opacity: 0.5; - } - } - - &.vertical-orientation { - flex-direction: column; - - .zoom-in-btn { - border-top-right-radius: 0.5rem; - border-bottom-left-radius: 0; - } - - .zoom-out-btn { - border-top-left-radius: 0; - border-bottom-left-radius: 0.5rem; - } - - .zoom-reset-btn { - border-top: 0.05rem solid lightgray; - border-bottom: 0.05rem solid lightgray; - } - } -} diff --git a/src/components/Diagram/DiagramZoomButtons/getDiagramZoomButtonsPosition.js b/src/components/Diagram/DiagramZoomButtons/getDiagramZoomButtonsPosition.js deleted file mode 100644 index 44c108e..0000000 --- a/src/components/Diagram/DiagramZoomButtons/getDiagramZoomButtonsPosition.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Return a transform css attribute object with translate property calculated - * based on the passed dimensions and position - * @param buttonsDim - * @param parentDim - * @param position - * @returns {{transform: string}} - */ -const getDiagramZoomButtonsPosition = (buttonsDim, parentDim, position) => { - let [left, top] = [0, 0]; - if (!Array.isArray(buttonsDim) || !Array.isArray(parentDim) || buttonsDim.length !== 2 || parentDim.length !== 2) { - return { transform: `translate(${left}px,${top}px)` }; - } - - const translateValues = { - 'bottom-right': [parentDim[0] - buttonsDim[0], parentDim[1] - buttonsDim[1]], - 'bottom-left': [0, parentDim[1] - buttonsDim[1]], - 'bottom-center': [parentDim[0] / 2 - buttonsDim[0] / 2, parentDim[1] - buttonsDim[1]], - 'top-right': [parentDim[0] - buttonsDim[0], 0], - 'top-left': [0, 0], - 'top-center': [parentDim[0] / 2 - buttonsDim[0] / 2, 0], - }; - [left, top] = translateValues[position]; - - return { transform: `translate(${left}px,${top}px)` }; -}; - -export default getDiagramZoomButtonsPosition; diff --git a/src/components/Diagram/DiagramZoomButtons/icons/zoomIn.png b/src/components/Diagram/DiagramZoomButtons/icons/zoomIn.png deleted file mode 100644 index 83ecdda..0000000 Binary files a/src/components/Diagram/DiagramZoomButtons/icons/zoomIn.png and /dev/null differ diff --git a/src/components/Diagram/DiagramZoomButtons/icons/zoomOut.png b/src/components/Diagram/DiagramZoomButtons/icons/zoomOut.png deleted file mode 100644 index c760e0f..0000000 Binary files a/src/components/Diagram/DiagramZoomButtons/icons/zoomOut.png and /dev/null differ diff --git a/src/components/Diagram/DiagramZoomButtons/icons/zoomReset.png b/src/components/Diagram/DiagramZoomButtons/icons/zoomReset.png deleted file mode 100644 index 869095b..0000000 Binary files a/src/components/Diagram/DiagramZoomButtons/icons/zoomReset.png and /dev/null differ diff --git a/src/components/Diagram/Link/Link.js b/src/components/Diagram/Link/Link.js index 7bb1bf4..dbef603 100644 --- a/src/components/Diagram/Link/Link.js +++ b/src/components/Diagram/Link/Link.js @@ -27,10 +27,10 @@ const Link = (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]); /* eslint-disable max-len */ + const inputPoint = getCoords(input, portRefs, nodeRefs, canvas); 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 = getCoords(output, portRefs, nodeRefs, canvas); /* eslint-enable max-len */ const pathOptions = { type: (input.type === 'port' || output.type === 'port') ? 'bezier' : 'curve', diff --git a/src/components/Diagram/README.md b/src/components/Diagram/README.md deleted file mode 100644 index 0f0afbb..0000000 --- a/src/components/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/Diagram/diagram.scss b/src/components/Diagram/diagram.scss index 264f246..d539a0e 100644 --- a/src/components/Diagram/diagram.scss +++ b/src/components/Diagram/diagram.scss @@ -1,202 +1,188 @@ .bi.bi-diagram { - &.pannable { - overflow: hidden; - } - height: 100%; + box-sizing: border-box; width: 100%; + height: 100%; - .bi.bi-diagram-canvas { + .bi-diagram-inners { 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; - - &.enlarge-diagram-canvas { - width: 312rem; - height: 312rem; - cursor: grab; - top: 50%; - left: 50%; - } + } - // ---------------------------- - // Diagram node general wrapper - // ---------------------------- - .bi.bi-diagram-node { - box-sizing: content-box; - position: absolute; - z-index: 50; - user-select: none; + // ---------------------------- + // Diagram node general wrapper + // ---------------------------- + .bi.bi-diagram-node { + box-sizing: content-box; + position: absolute; + z-index: 50; + user-select: none; + + .bi-port-wrapper { + display: flex; + + .bi-input-ports, + .bi-output-ports { + flex: 1 1; + + .bi-diagram-port { + transition: background-color 0.25s ease-in-out; + background-color: rgba(0, 0, 0, 0.08); + width: 1.25rem; + height: 1.25rem; + margin-bottom: 0.25rem; - .bi-port-wrapper { - display: flex; - - .bi-input-ports, - .bi-output-ports { - flex: 1 1; - - .bi-diagram-port { - transition: background-color 0.25s ease-in-out; - background-color: rgba(0, 0, 0, 0.08); - width: 1.25rem; - height: 1.25rem; - margin-bottom: 0.25rem; - - &:hover { - background-color: rgba(0, 0, 0, 0.1); - } + &:hover { + background-color: rgba(0, 0, 0, 0.1); } } - - /* stylelint-disable-next-line no-descending-specificity */ - .bi-output-ports .bi-diagram-port { - margin-left: auto; - } } + /* stylelint-disable-next-line no-descending-specificity */ + .bi-output-ports .bi-diagram-port { + margin-left: auto; + } + } - // ---------------------------- - // Default node - // ---------------------------- - &.bi-diagram-node-default { - transition: box-shadow 0.25s ease-in-out, border 0.3s ease-out; - border: 0.07rem solid #8795a1; - background-color: #dae1e7; - color: #606f7b; - border-radius: 0.25rem; - box-shadow: 0 0.07rem 0.2rem 0 rgba(0, 0, 0, 0.1), 0 0.07rem 0.125rem 0 rgba(0, 0, 0, 0.06); - padding: 0.5rem; - &:hover { - box-shadow: 0 0.125rem 1rem -0.2rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.3rem -125rem rgba(0, 0, 0, 0.05); - } + // ---------------------------- + // Default node + // ---------------------------- + &.bi-diagram-node-default { + transition: box-shadow 0.25s ease-in-out, border 0.3s ease-out; + border: 0.07rem solid #8795a1; + background-color: #dae1e7; + color: #606f7b; + border-radius: 0.25rem; + box-shadow: 0 0.07rem 0.2rem 0 rgba(0, 0, 0, 0.1), 0 0.07rem 0.125rem 0 rgba(0, 0, 0, 0.06); + padding: 0.5rem; + + &:hover { + box-shadow: 0 0.125rem 1rem -0.2rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.3rem -125rem rgba(0, 0, 0, 0.05); + } - .bi-port-wrapper { - margin-left: -0.5rem; - margin-right: -0.5rem; - } + .bi-port-wrapper { + margin-left: -0.5rem; + margin-right: -0.5rem; } } + } + + // ---------------------------- + // Link canvas layer + // ---------------------------- + .bi-link-canvas-layer { + pointer-events: none; + width: 100%; + height: 100%; + z-index: 0; + position: absolute; + overflow: visible; + top: 0; + right: 0; + bottom: 0; + left: 0; // ---------------------------- - // Link canvas layer + // Segment // ---------------------------- - .bi-link-canvas-layer { - pointer-events: none; - width: 100%; - height: 100%; - z-index: 0; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - - // ---------------------------- - // Segment - // ---------------------------- - .bi-diagram-segment { - path { - stroke: #dae1e7; - stroke-width: 0.25rem; - stroke-dasharray: 10, 2; - fill: transparent; - animation: BiDashSegmentAnimation 1s linear infinite; - } + .bi-diagram-segment { + path { + stroke: #dae1e7; + stroke-width: 0.25rem; + stroke-dasharray: 10, 2; + fill: transparent; + animation: BiDashSegmentAnimation 1s linear infinite; + } - circle { - stroke: #dae1e7; - stroke-width: 0.15rem; - fill: #88cdff; - } + circle { + stroke: #dae1e7; + stroke-width: 0.15rem; + fill: #88cdff; } + } - // ---------------------------- - // Link item - // ---------------------------- - .bi-diagram-link { + // ---------------------------- + // Link item + // ---------------------------- + .bi-diagram-link { + pointer-events: stroke; + + // link path + .bi-link-path { + stroke: #dae1e7; + stroke-width: 0.25rem; pointer-events: stroke; + fill: transparent; + cursor: pointer; + user-select: none; + } - // link path - .bi-link-path { - stroke: #dae1e7; - stroke-width: 0.25rem; - pointer-events: stroke; - fill: transparent; - cursor: pointer; - user-select: none; - } + // link ghost + .bi-link-ghost { + pointer-events: stroke; + stroke: transparent; + stroke-width: 1.2rem; + fill: transparent; + cursor: pointer; + } + + // hover + &:hover .bi-link-path { + stroke: #88cdff; + stroke-dasharray: 10, 2; + animation: BiDashSegmentAnimation 1s linear infinite; + } - // link ghost + // readonly link + &.readonly-link { .bi-link-ghost { - pointer-events: stroke; - stroke: transparent; - stroke-width: 1.2rem; - fill: transparent; - cursor: pointer; + cursor: not-allowed; } - // hover - &:hover .bi-link-path { - stroke: #88cdff; - stroke-dasharray: 10, 2; - animation: BiDashSegmentAnimation 1s linear infinite; + .bi-link-path { + cursor: not-allowed; + stroke: #b8c2cc; + stroke-dasharray: none; } - // readonly link - &.readonly-link { - .bi-link-ghost { - cursor: not-allowed; - } - - .bi-link-path { - cursor: not-allowed; - stroke: #b8c2cc; - stroke-dasharray: none; - } - - &:hover { - stroke: #b8c2cc; - stroke-dasharray: none; - animation: none; - } + &:hover { + stroke: #b8c2cc; + stroke-dasharray: none; + animation: none; } + } - /* stylelint-disable-next-line */ - foreignObject { - width: 100%; - height: 100%; - overflow: visible; - pointer-events: none; - } + /* stylelint-disable-next-line */ + foreignObject { + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + } - // ---------------------------- - // Link label - // ---------------------------- - .bi-diagram-link-label { - display: inline-block; - color: #fff; - background-color: #3d4852; - border-radius: 0.25rem; - padding: 0.25rem; - text-align: center; - font-size: 0.875rem; - user-select: none; - min-width: 3rem; - transform: translate(-50%, -50%); - } + // ---------------------------- + // Link label + // ---------------------------- + .bi-diagram-link-label { + display: inline-block; + color: #fff; + background-color: #3d4852; + border-radius: 0.25rem; + padding: 0.25rem; + text-align: center; + font-size: 0.875rem; + user-select: none; + min-width: 3rem; + transform: translate(-50%, -50%); } } } } + // ---------------------------- // Involved animations // ---------------------------- 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/index.js b/src/index.js index 4eef99b..3def994 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,9 @@ import Diagram from './components/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 index 192f498..8af066d 100644 --- a/src/shared/Constants.js +++ b/src/shared/Constants.js @@ -1,3 +1,16 @@ export const isTouch = 'ontouchstart' in window; -export default Object.freeze({ isTouch }); +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/Events.js b/src/shared/Events.js deleted file mode 100644 index d6d2d71..0000000 --- a/src/shared/Events.js +++ /dev/null @@ -1,8 +0,0 @@ -import { isTouch } from './Constants'; - -export default Object.freeze({ - MOUSE_START: isTouch ? 'touchstart' : 'mousedown', - MOUSE_MOVE: isTouch ? 'touchmove' : 'mousemove', - MOUSE_END: isTouch ? 'touchend' : 'mouseup', - WHEEL: 'wheel', -}); 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/tests/Diagram.spec.js b/tests/Diagram.spec.js index bc6c518..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); @@ -26,21 +26,21 @@ describe('Diagram component', () => { it('should accept an "id" prop', () => { const { container } = render(); - const wrapper = container.querySelector('.bi.bi-diagram-canvas'); + const wrapper = container.querySelector('.bi.bi-diagram'); expect(wrapper.id).to.equal('foo'); }); it('should allow adding custom classes', () => { const { container } = render(); - const wrapper = container.querySelector('.bi.bi-diagram-canvas'); + const wrapper = container.querySelector('.bi.bi-diagram'); expect(wrapper.getAttribute('class').split(' ')).to.include.members(['foo']); }); it('should allow to define custom style', () => { const { container } = render(); - const wrapper = container.querySelector('.bi.bi-diagram-canvas'); + const wrapper = container.querySelector('.bi.bi-diagram'); expect(wrapper.getAttribute('style')).to.equal('margin: 10px;'); }); diff --git a/tests/DiagramCanvas.spec.js b/tests/DiagramCanvas.spec.js index dcedce2..1fc06f0 100644 --- a/tests/DiagramCanvas.spec.js +++ b/tests/DiagramCanvas.spec.js @@ -1,6 +1,6 @@ import React from 'react'; -import { render, cleanup, fireEvent } from '@testing-library/react'; -import DiagramCanvas from '../dist/Diagram/DiagramCanvas/DiagramCanvas'; +import { render, cleanup } from '@testing-library/react'; +import DiagramCanvas from '../dist/components/Diagram/DiagramCanvas/DiagramCanvas'; describe('DiagramCanvas component', () => { afterEach(cleanup); @@ -18,77 +18,4 @@ describe('DiagramCanvas component', () => { expect(wrapper.getAttribute('class').split(' ')).to.include.members(['bi', 'bi-diagram']); }); - - it('should enlarge the diagram canvas when it is draggable', () => { - const { container } = render(); - const wrapper = container.querySelector('.bi.bi-diagram .bi-diagram-canvas'); - - expect(wrapper.getAttribute('class').split(' ')) - .to.include.members(['bi', 'bi-diagram-canvas', 'enlarge-diagram-canvas']); - }); - - it('should be zoomable', () => { - const { container } = render(); - const zoomButtons = container.querySelector('.diagram-zoom-buttons'); - const zoomInBtn = zoomButtons.querySelector('.zoom-in-btn'); - const zoomResetBtn = zoomButtons.querySelector('.zoom-reset-btn'); - const zoomOutBtn = zoomButtons.querySelector('.zoom-out-btn'); - - expect(zoomButtons).to.exist; - expect(zoomInBtn).to.exist; - expect(zoomOutBtn).to.exist; - expect(zoomResetBtn).to.exist; - }); - - it('should change the css scale value when clicking on zoom buttons', () => { - const { container } = render(); - const zoomButtons = container.querySelector('.diagram-zoom-buttons'); - const wrapper = container.querySelector('.bi.bi-diagram-canvas'); - const zoomInBtn = zoomButtons.querySelector('.zoom-in-btn'); - const zoomResetBtn = zoomButtons.querySelector('.zoom-reset-btn'); - const zoomOutBtn = zoomButtons.querySelector('.zoom-out-btn'); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(1)'); - - fireEvent.click(zoomInBtn); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(1.1)'); - fireEvent.click(zoomInBtn); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(1.2000000000000002)'); - - fireEvent.click(zoomOutBtn); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(1.1)'); - - fireEvent.click(zoomResetBtn); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(1)'); - }); - - it('can\'t go over the defined maxZoom or minZoom provided props', () => { - const { container } = render(); - const zoomButtons = container.querySelector('.diagram-zoom-buttons'); - const wrapper = container.querySelector('.bi.bi-diagram-canvas'); - const zoomInBtn = zoomButtons.querySelector('.zoom-in-btn'); - const zoomOutBtn = zoomButtons.querySelector('.zoom-out-btn'); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(1)'); - - fireEvent.click(zoomInBtn); - fireEvent.click(zoomInBtn); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(1.2000000000000002)'); - expect(zoomInBtn.getAttribute('class').split(' ')).to.include.members(['zoom-in-btn', 'disabled']); - fireEvent.click(zoomInBtn); - expect(wrapper.style.transform).to.not.equal('translate(0px, 0px) scale(1.3000000000000003)'); - - fireEvent.click(zoomOutBtn); - fireEvent.click(zoomOutBtn); - fireEvent.click(zoomOutBtn); - fireEvent.click(zoomOutBtn); - - expect(wrapper.style.transform).to.be.equal('translate(0px, 0px) scale(0.9)'); - expect(zoomOutBtn.getAttribute('class').split(' ')).to.include.members(['zoom-out-btn', 'disabled']); - }); }); 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/DiagramZoomButtons.spec.js b/tests/DiagramZoomButtons.spec.js deleted file mode 100644 index 9a889d1..0000000 --- a/tests/DiagramZoomButtons.spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import DiagramZoomButtons from '../dist/Diagram/DiagramZoomButtons/DiagramZoomButtons'; -import noop from './utils/noop'; - -describe('DiagramZoomButtons component', () => { - it('should render without explode', () => { - const { container } = render( - , - ); - - should.exist(container); - expect(container.querySelector('div')).to.exist; - }); - - it('should render zoom buttons', () => { - const { container } = render( - , - ); - const zoomInBtn = container.querySelector('.zoom-in-btn'); - const zoomResetBtn = container.querySelector('.zoom-reset-btn'); - const zoomOutBtn = container.querySelector('.zoom-out-btn'); - - expect(zoomInBtn).to.exist; - expect(zoomOutBtn).to.exist; - expect(zoomResetBtn).to.exist; - }); - - it('should perform provided zoom functions on buttons click', () => { - const onZoomIn = sinon.spy(); - const onZoomOut = sinon.spy(); - const onZoomReset = sinon.spy(); - const { container } = render( - , - ); - const zoomInBtn = container.querySelector('.zoom-in-btn'); - const zoomResetBtn = container.querySelector('.zoom-reset-btn'); - const zoomOutBtn = container.querySelector('.zoom-out-btn'); - - fireEvent.click(zoomInBtn); - - expect(onZoomIn.calledOnce).to.be.true; - fireEvent.click(zoomOutBtn); - - expect(onZoomOut.calledOnce).to.be.true; - - fireEvent.click(zoomResetBtn); - - expect(onZoomReset.calledOnce).to.be.true; - }); -}); 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/getCanvasDragLimits.spec.js b/tests/getCanvasDragLimits.spec.js index cb9da8e..23d86a1 100644 --- a/tests/getCanvasDragLimits.spec.js +++ b/tests/getCanvasDragLimits.spec.js @@ -1,4 +1,4 @@ -import getCanvasDragLimits from '../dist/Diagram/DiagramCanvas/utils/getCanvasDragLimits'; +import getCanvasDragLimits from '../dist/components/Diagram/DiagramCanvas/utils/getCanvasDragLimits'; describe('getCanvasDragLimits function', () => { it('should be a function', () => { diff --git a/tests/getDiagramCanvasCoords.spec.js b/tests/getDiagramCanvasCoords.spec.js index 598d189..7abc6fc 100644 --- a/tests/getDiagramCanvasCoords.spec.js +++ b/tests/getDiagramCanvasCoords.spec.js @@ -1,4 +1,4 @@ -import getDiagramCanvasCoords from '../dist/Diagram/DiagramCanvas/utils/getDiagramCanvasCoords'; +import getDiagramCanvasCoords from '../dist/components/Diagram/DiagramCanvas/utils/getDiagramCanvasCoords'; describe('getDiagramCanvasCoords', () => { it('should be a function', () => { diff --git a/tests/getDiagramZoomButtonsPosition.spec.js b/tests/getDiagramZoomButtonsPosition.spec.js deleted file mode 100644 index 995d84a..0000000 --- a/tests/getDiagramZoomButtonsPosition.spec.js +++ /dev/null @@ -1,14 +0,0 @@ -import getDiagramZoomButtonsPosition from '../dist/Diagram/DiagramZoomButtons/getDiagramZoomButtonsPosition'; - -describe('getDiagramZoomButtonsPosition function', () => { - it('should be a function', () => { - expect(getDiagramZoomButtonsPosition).to.be.a('function'); - }); - - it('should returns a transform object', () => { - const transformObj = getDiagramZoomButtonsPosition([36, 108], [640, 320], 'top-right'); - - expect(transformObj).to.be.an('object'); - expect(transformObj.transform).to.be.equal('translate(604px,0px)'); - }); -}); 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'); });