* fix(ts): use strict mode strictNullChecks etc

* chore: Use extended React.HTMLAttributes<> (#41)

* refactor(code-format): add prettier closes #42

* feat(renderer): add snap to grid option closes #20

* chore(dependabot): use develop as target branch
This commit is contained in:
Moritz
2019-10-21 20:58:28 +02:00
committed by GitHub
parent ce210eb5dc
commit a793f8c2ff
80 changed files with 2634 additions and 1247 deletions
+37 -14
View File
@@ -1,30 +1,40 @@
import React, {memo} from 'react';
import React, { memo, HTMLAttributes, CSSProperties } from 'react';
import classnames from 'classnames';
import { useStoreState } from '../../store/hooks';
import { GridType } from '../../types';
interface GridProps {
interface GridProps extends HTMLAttributes<SVGElement> {
backgroundType?: GridType;
gap?: number;
color?: string;
size?: number;
style?: React.CSSProperties;
className?: string | null;
};
}
const baseStyles: React.CSSProperties = {
const baseStyles: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
};
const createGridLines = (width: number, height: number, xOffset: number, yOffset: number, gap: number): string => {
const createGridLines = (
width: number,
height: number,
xOffset: number,
yOffset: number,
gap: number
): string => {
const lineCountX = Math.ceil(width / gap) + 1;
const lineCountY = Math.ceil(height / gap) + 1;
const xValues = Array.from({length: lineCountX}, (_, i) => `M${i * gap + xOffset} 0 V${height}`);
const yValues = Array.from({length: lineCountY}, (_, i) => `M0 ${i * gap + yOffset} H${width}`);
const xValues = Array.from(
{ length: lineCountX },
(_, i) => `M${i * gap + xOffset} 0 V${height}`
);
const yValues = Array.from(
{ length: lineCountY },
(_, i) => `M0 ${i * gap + yOffset} H${width}`
);
return [...xValues, ...yValues].join(' ');
};
@@ -40,11 +50,12 @@ const createGridDots = (
const lineCountX = Math.ceil(width / gap) + 1;
const lineCountY = Math.ceil(height / gap) + 1;
const values = Array.from({length: lineCountX}, (_, col) => {
const values = Array.from({ length: lineCountX }, (_, col) => {
const x = col * gap + xOffset;
return Array.from({length: lineCountY}, (_, row) => {
return Array.from({ length: lineCountY }, (_, row) => {
const y = row * gap + yOffset;
return `M${x} ${y - size} l${size} ${size} l${-size} ${size} l${-size} ${-size}z`;
return `M${x} ${y -
size} l${size} ${size} l${-size} ${size} l${-size} ${-size}z`;
}).join(' ');
});
@@ -52,7 +63,14 @@ const createGridDots = (
};
const Grid = memo(
({gap = 24, color = '#aaa', size = 0.5, style = {}, className = null, backgroundType = GridType.Dots}: GridProps) => {
({
gap = 24,
color = '#aaa',
size = 0.5,
style = {},
className = '',
backgroundType = GridType.Dots,
}: GridProps) => {
const {
width,
height,
@@ -73,7 +91,12 @@ const Grid = memo(
const stroke = isLines ? color : 'none';
return (
<svg width={width} height={height} style={{...baseStyles, ...style}} className={gridClasses}>
<svg
width={width}
height={height}
style={{ ...baseStyles, ...style }}
className={gridClasses}
>
<path fill={fill} stroke={stroke} strokeWidth={size} d={path} />
</svg>
);
+23 -14
View File
@@ -7,16 +7,22 @@ interface ConnectionLineProps {
connectionSourceId: ElementId;
connectionPositionX: number;
connectionPositionY: number;
connectionLineType?: string | null;
connectionLineType?: string | null;
nodes: Node[];
transform: Transform;
connectionLineStyle?: SVGAttributes<{}>;
className?: string;
};
}
export default ({
connectionSourceId, connectionLineStyle = {}, connectionPositionX, connectionPositionY,
connectionLineType, nodes = [], className, transform
connectionSourceId,
connectionLineStyle = {},
connectionPositionX,
connectionPositionY,
connectionLineType,
nodes = [],
className,
transform,
}: ConnectionLineProps) => {
const [sourceNode, setSourceNode] = useState<Node | null>(null);
const hasHandleId = connectionSourceId.includes('__');
@@ -25,7 +31,7 @@ export default ({
const handleId = hasHandleId ? sourceIdSplitted[1] : null;
useEffect(() => {
const nextSourceNode = nodes.find(n => n.id === nodeId) || null;
const nextSourceNode = nodes.find(n => n.id === nodeId) || null;
setSourceNode(nextSourceNode);
}, []);
@@ -35,11 +41,17 @@ export default ({
const edgeClasses: string = cx('react-flow__edge', 'connection', className);
const sourceHandle = handleId ?
sourceNode.__rg.handleBounds.source.find((d: HandleElement) => d.id === handleId) :
sourceNode.__rg.handleBounds.source[0];
const sourceHandleX = sourceHandle ? sourceHandle.x + (sourceHandle.width / 2) : sourceNode.__rg.width / 2;
const sourceHandleY = sourceHandle ? sourceHandle.y + (sourceHandle.height / 2) : sourceNode.__rg.height;
const sourceHandle = handleId
? sourceNode.__rg.handleBounds.source.find(
(d: HandleElement) => d.id === handleId
)
: sourceNode.__rg.handleBounds.source[0];
const sourceHandleX = sourceHandle
? sourceHandle.x + sourceHandle.width / 2
: sourceNode.__rg.width / 2;
const sourceHandleY = sourceHandle
? sourceHandle.y + sourceHandle.height / 2
: sourceNode.__rg.height;
const sourceX = sourceNode.__rg.position.x + sourceHandleX;
const sourceY = sourceNode.__rg.position.y + sourceHandleY;
@@ -58,10 +70,7 @@ export default ({
return (
<g className={edgeClasses}>
<path
d={dAttr}
{...connectionLineStyle}
/>
<path d={dAttr} {...connectionLineStyle} />
</g>
);
};
+29 -21
View File
@@ -2,28 +2,36 @@ import React, { memo } from 'react';
import { EdgeBezierProps } from '../../types';
export default memo(({
sourceX, sourceY, targetX, targetY,
sourcePosition = 'bottom', targetPosition = 'top', style = {}
}: EdgeBezierProps) => {
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
export default memo(
({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = 'bottom',
targetPosition = 'top',
style = {},
}: EdgeBezierProps) => {
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
let dAttr = `M${sourceX},${sourceY} C${sourceX},${centerY} ${targetX},${centerY} ${targetX},${targetY}`;
let dAttr = `M${sourceX},${sourceY} C${sourceX},${centerY} ${targetX},${centerY} ${targetX},${targetY}`;
if (['left', 'right'].includes(sourcePosition) && ['left', 'right'].includes(targetPosition)) {
const xOffset = Math.abs(targetX - sourceX) / 2;
const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
if (
['left', 'right'].includes(sourcePosition) &&
['left', 'right'].includes(targetPosition)
) {
const xOffset = Math.abs(targetX - sourceX) / 2;
const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
dAttr = `M${sourceX},${sourceY} C${centerX},${sourceY} ${centerX},${targetY} ${targetX},${targetY}`;
} else if (['left', 'right'].includes(sourcePosition) || ['left', 'right'].includes(targetPosition)) {
dAttr = `M${sourceX},${sourceY} C${sourceX},${targetY} ${sourceX},${targetY} ${targetX},${targetY}`;
dAttr = `M${sourceX},${sourceY} C${centerX},${sourceY} ${centerX},${targetY} ${targetX},${targetY}`;
} else if (
['left', 'right'].includes(sourcePosition) ||
['left', 'right'].includes(targetPosition)
) {
dAttr = `M${sourceX},${sourceY} C${sourceX},${targetY} ${sourceX},${targetY} ${targetX},${targetY}`;
}
return <path {...style} d={dAttr} />;
}
return (
<path
{...style}
d={dAttr}
/>
);
});
);
+12 -12
View File
@@ -2,16 +2,16 @@ import React, { memo } from 'react';
import { EdgeProps } from '../../types';
export default memo(({
sourceX, sourceY, targetX, targetY, style = {}
} : EdgeProps) => {
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
export default memo(
({ sourceX, sourceY, targetX, targetY, style = {} }: EdgeProps) => {
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
return (
<path
{...style}
d={`M ${sourceX},${sourceY}L ${sourceX},${centerY}L ${targetX},${centerY}L ${targetX},${targetY}`}
/>
);
});
return (
<path
{...style}
d={`M ${sourceX},${sourceY}L ${sourceX},${centerY}L ${targetX},${centerY}L ${targetX},${targetY}`}
/>
);
}
);
+7 -10
View File
@@ -2,13 +2,10 @@ import React, { memo } from 'react';
import { EdgeProps } from '../../types';
export default memo(({
sourceX, sourceY, targetX, targetY, style = {}
}: EdgeProps) => {
return (
<path
{...style}
d={`M ${sourceX},${sourceY}L ${targetX},${targetY}`}
/>
);
});
export default memo(
({ sourceX, sourceY, targetX, targetY, style = {} }: EdgeProps) => {
return (
<path {...style} d={`M ${sourceX},${sourceY}L ${targetX},${targetY}`} />
);
}
);
+47 -33
View File
@@ -3,42 +3,56 @@ import cx from 'classnames';
import { isInputDOMNode } from '../../utils';
import store from '../../store';
import { EdgeWrapperProps } from '../../types';
import { ElementId, Edge, EdgeCompProps } from '../../types';
export default (EdgeComponent: ComponentType<EdgeWrapperProps>) => {
const EdgeWrapper = memo(({
id, source, target, type,
animated, selected, onClick,
...rest
}: EdgeWrapperProps) => {
const edgeClasses = cx('react-flow__edge', { selected, animated });
const onEdgeClick = (evt: MouseEvent) => {
if (isInputDOMNode(evt)) {
return false;
}
interface EdgeWrapperProps {
id: ElementId;
source: ElementId;
target: ElementId;
type: any;
onClick: (edge: Edge) => void;
animated: boolean;
selected: boolean;
}
store.dispatch.setSelectedElements({ id, source, target });
onClick({ id, source, target, type });
};
export default (EdgeComponent: ComponentType<EdgeCompProps>) => {
const EdgeWrapper = memo(
({
id,
source,
target,
type,
animated,
selected,
onClick,
...rest
}: EdgeWrapperProps) => {
const edgeClasses = cx('react-flow__edge', { selected, animated });
const onEdgeClick = (evt: MouseEvent): void => {
if (isInputDOMNode(evt)) {
return;
}
return (
<g
className={edgeClasses}
onClick={onEdgeClick}
>
<EdgeComponent
id={id}
source={source}
target={target}
type={type}
animated={animated}
selected={selected}
onClick={onClick}
{...rest}
/>
</g>
);
});
store.dispatch.setSelectedElements({ id, source, target });
onClick({ id, source, target, type });
};
return (
<g className={edgeClasses} onClick={onEdgeClick}>
<EdgeComponent
id={id}
source={source}
target={target}
type={type}
animated={animated}
selected={selected}
onClick={onClick}
{...rest}
/>
</g>
);
}
);
EdgeWrapper.displayName = 'EdgeWrapper';
+79 -42
View File
@@ -1,41 +1,54 @@
import React, { memo, MouseEvent as ReactMouseEvent } from 'react';
import cx from 'classnames';
import { HandleType, ElementId, Position, XYPosition, OnConnectFunc, Connection } from '../../types';
import {
HandleType,
ElementId,
Position,
XYPosition,
OnConnectFunc,
Connection,
} from '../../types';
type ValidConnectionFunc = (connection: Connection) => boolean;
type SetSourceIdFunc = (nodeId: ElementId | null) => void;
interface BaseHandleProps {
type: HandleType;
nodeId: ElementId;
onConnect: OnConnectFunc;
position: Position;
setSourceId: (nodeId: ElementId) => void;
setSourceId: SetSourceIdFunc;
setPosition: (pos: XYPosition) => void;
isValidConnection: ValidConnectionFunc;
id?: ElementId | boolean;
id?: ElementId | boolean;
className?: string;
};
}
type Result = {
elementBelow: Element;
elementBelow: Element | null;
isValid: boolean;
connection: Connection;
isHoveringHandle: boolean;
};
function onMouseDown(
evt: ReactMouseEvent, nodeId: ElementId, setSourceId: (nodeId: ElementId) => void, setPosition: (pos: XYPosition) => any,
onConnect: OnConnectFunc, isTarget: boolean, isValidConnection: ValidConnectionFunc
evt: ReactMouseEvent,
nodeId: ElementId,
setSourceId: SetSourceIdFunc,
setPosition: (pos: XYPosition) => any,
onConnect: OnConnectFunc,
isTarget: boolean,
isValidConnection: ValidConnectionFunc
): void {
const reactFlowNode = document.querySelector('.react-flow');
if (!reactFlowNode) {
return null;
return;
}
const containerBounds = reactFlowNode.getBoundingClientRect();
let recentHoveredHandle: Element = null;
let recentHoveredHandle: Element;
setPosition({
x: evt.clientX - containerBounds.left,
@@ -43,9 +56,9 @@ function onMouseDown(
});
setSourceId(nodeId);
function resetRecentHandle() {
function resetRecentHandle(): void {
if (!recentHoveredHandle) {
return false;
return;
}
recentHoveredHandle.classList.remove('valid');
@@ -55,14 +68,19 @@ function onMouseDown(
// checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 }
function checkElementBelowIsValid(evt: MouseEvent) {
const elementBelow = document.elementFromPoint(evt.clientX, evt.clientY);
const result: Result = {
elementBelow,
isValid: false,
connection: { source: null, target: null },
isHoveringHandle: false
isHoveringHandle: false,
};
if (elementBelow && (elementBelow.classList.contains('target') || elementBelow.classList.contains('source'))) {
if (
elementBelow &&
(elementBelow.classList.contains('target') ||
elementBelow.classList.contains('source'))
) {
let connection: Connection = { source: null, target: null };
if (isTarget) {
@@ -89,7 +107,12 @@ function onMouseDown(
y: evt.clientY - containerBounds.top,
});
const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid(evt);
const {
connection,
elementBelow,
isValid,
isHoveringHandle,
} = checkElementBelowIsValid(evt);
if (!isHoveringHandle) {
return resetRecentHandle();
@@ -97,7 +120,7 @@ function onMouseDown(
const isOwnHandle = connection.source === connection.target;
if (!isOwnHandle) {
if (!isOwnHandle && elementBelow) {
recentHoveredHandle = elementBelow;
elementBelow.classList.add('connecting');
elementBelow.classList.toggle('valid', isValid);
@@ -105,7 +128,7 @@ function onMouseDown(
}
function onMouseUp(evt: MouseEvent) {
const { connection, isValid } = checkElementBelowIsValid(evt);
const { connection, isValid } = checkElementBelowIsValid(evt);
if (isValid) {
onConnect(connection);
@@ -119,37 +142,51 @@ function onMouseDown(
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('mouseup', onMouseUp);
}
const BaseHandle = memo(({
type, nodeId, onConnect, position,
setSourceId, setPosition, className,
id = false, isValidConnection, ...rest
}: BaseHandleProps) => {
const isTarget = type === 'target';
const handleClasses = cx(
'react-flow__handle',
className,
const BaseHandle = memo(
({
type,
nodeId,
onConnect,
position,
{ source: !isTarget, target: isTarget }
);
setSourceId,
setPosition,
className,
id = false,
isValidConnection,
...rest
}: BaseHandleProps) => {
const isTarget = type === 'target';
const handleClasses = cx('react-flow__handle', className, position, {
source: !isTarget,
target: isTarget,
});
const nodeIdWithHandleId = id ? `${nodeId}__${id}` : nodeId;
const nodeIdWithHandleId = id ? `${nodeId}__${id}` : nodeId;
return (
<div
data-nodeid={nodeIdWithHandleId}
data-handlepos={position}
className={handleClasses}
onMouseDown={evt => onMouseDown(evt,
nodeIdWithHandleId, setSourceId, setPosition,
onConnect, isTarget, isValidConnection
)}
{...rest}
/>
);
});
return (
<div
data-nodeid={nodeIdWithHandleId}
data-handlepos={position}
className={handleClasses}
onMouseDown={evt =>
onMouseDown(
evt,
nodeIdWithHandleId,
setSourceId,
setPosition,
onConnect,
isTarget,
isValidConnection
)
}
{...rest}
/>
);
}
);
BaseHandle.displayName = 'BaseHandle';
+45 -34
View File
@@ -2,45 +2,56 @@ import React, { memo, useContext } from 'react';
import { useStoreActions, useStoreState } from '../../store/hooks';
import BaseHandle from './BaseHandle';
import NodeIdContext from '../../contexts/NodeIdContext'
import NodeIdContext from '../../contexts/NodeIdContext';
import { HandleType, ElementId, Position, OnConnectParams, OnConnectFunc } from '../../types';
import {
HandleType,
ElementId,
Position,
Connection,
OnConnectFunc,
} from '../../types';
interface HandleProps {
type: HandleType,
position: Position,
onConnect?: OnConnectFunc,
isValidConnection?: () => boolean
};
type: HandleType;
position: Position;
onConnect?: OnConnectFunc;
isValidConnection?: () => boolean;
}
const Handle = memo(({
onConnect = _ => {}, type = 'source', position = 'top', isValidConnection = () => true,
...rest
}: HandleProps) => {
const nodeId = useContext(NodeIdContext) as ElementId;
const { setPosition, setSourceId } = useStoreActions(a => ({
setPosition: a.setConnectionPosition,
setSourceId: a.setConnectionSourceId
}));
const onConnectAction = useStoreState(s => s.onConnect);
const onConnectExtended = (params: OnConnectParams) => {
onConnectAction(params);
onConnect(params);
};
const Handle = memo(
({
onConnect = _ => {},
type = 'source',
position = 'top',
isValidConnection = () => true,
...rest
}: HandleProps) => {
const nodeId = useContext(NodeIdContext) as ElementId;
const { setPosition, setSourceId } = useStoreActions(a => ({
setPosition: a.setConnectionPosition,
setSourceId: a.setConnectionSourceId,
}));
const onConnectAction = useStoreState(s => s.onConnect);
const onConnectExtended = (params: Connection) => {
onConnectAction(params);
onConnect(params);
};
return (
<BaseHandle
nodeId={nodeId}
setPosition={setPosition}
setSourceId={setSourceId}
onConnect={onConnectExtended}
type={type}
position={position}
isValidConnection={isValidConnection}
{...rest}
/>
);
});
return (
<BaseHandle
nodeId={nodeId}
setPosition={setPosition}
setSourceId={setSourceId}
onConnect={onConnectExtended}
type={type}
position={position}
isValidConnection={isValidConnection}
{...rest}
/>
);
}
);
Handle.displayName = 'Handle';
+1 -1
View File
@@ -7,7 +7,7 @@ const nodeStyles: CSSProperties = {
background: '#ff6060',
padding: 10,
borderRadius: 5,
width: 150
width: 150,
};
export default ({ data, style }: NodeProps) => (
+1 -1
View File
@@ -7,7 +7,7 @@ const nodeStyles: CSSProperties = {
background: '#9999ff',
padding: 10,
borderRadius: 5,
width: 150
width: 150,
};
export default ({ data, style }: NodeProps) => (
+1 -1
View File
@@ -7,7 +7,7 @@ const nodeStyles: CSSProperties = {
background: '#55dd99',
padding: 10,
borderRadius: 5,
width: 150
width: 150,
};
export default ({ data, style }: NodeProps) => (
+208 -106
View File
@@ -1,4 +1,11 @@
import React, { useEffect, useRef, useState, memo, ComponentType } from 'react';
import React, {
useEffect,
useRef,
useState,
memo,
ComponentType,
CSSProperties,
} from 'react';
import { DraggableCore, DraggableEvent } from 'react-draggable';
import cx from 'classnames';
import { ResizeObserver } from 'resize-observer';
@@ -6,7 +13,28 @@ import { ResizeObserver } from 'resize-observer';
import { getDimensions, isInputDOMNode } from '../../utils';
import { Provider } from '../../contexts/NodeIdContext';
import store from '../../store';
import { NodeComponentProps, Node, XYPosition, HandleElement, Position, Transform, ElementId } from '../../types';
import {
Node,
XYPosition,
HandleElement,
Position,
Transform,
ElementId,
NodeComponentProps,
} from '../../types';
interface WrapNodeProps {
id: ElementId;
type: string;
data: any;
selected: boolean;
transform: Transform;
xPos: number;
yPos: number;
onClick: (node: Node) => void | undefined;
onNodeDragStop: (node: Node) => void;
style?: CSSProperties;
}
const isHandle = (evt: MouseEvent | DraggableEvent) => {
const target = evt.target as HTMLElement;
@@ -14,165 +42,239 @@ const isHandle = (evt: MouseEvent | DraggableEvent) => {
return (
target.className &&
target.className.includes &&
(target.className.includes('source') || target.className.includes('target'))
(target.className.includes('source') || target.className.includes('target'))
);
};
const getHandleBounds = (
selector: string, nodeElement: HTMLDivElement, parentBounds: ClientRect | DOMRect, k: number
): HandleElement => {
selector: string,
nodeElement: HTMLDivElement,
parentBounds: ClientRect | DOMRect,
k: number
): HandleElement[] | null => {
const handles = nodeElement.querySelectorAll(selector);
if (!handles || !handles.length) {
return null;
}
return [].map.call(handles, (handle: HTMLDivElement): HandleElement => {
const bounds = handle.getBoundingClientRect();
const dimensions = getDimensions(handle);
const nodeIdAttr = handle.getAttribute('data-nodeid');
const handlePosition = handle.getAttribute('data-handlepos') as unknown as Position;
const nodeIdSplitted = nodeIdAttr.split('__');
const handlesArray = Array.from(handles) as HTMLDivElement[];
let handleId = null;
return handlesArray.map(
(handle): HandleElement => {
const bounds = handle.getBoundingClientRect();
const dimensions = getDimensions(handle);
const nodeIdAttr = handle.getAttribute('data-nodeid');
const handlePosition = (handle.getAttribute(
'data-handlepos'
) as unknown) as Position;
const nodeIdSplitted = nodeIdAttr ? nodeIdAttr.split('__') : null;
if (nodeIdSplitted) {
handleId = (nodeIdSplitted.length ? nodeIdSplitted[1] : nodeIdSplitted) as string;
let handleId = null;
if (nodeIdSplitted) {
handleId = (nodeIdSplitted.length
? nodeIdSplitted[1]
: nodeIdSplitted) as string;
}
return {
id: handleId,
position: handlePosition,
x: (bounds.left - parentBounds.left) * (1 / k),
y: (bounds.top - parentBounds.top) * (1 / k),
...dimensions,
};
}
return {
id: handleId,
position: handlePosition,
x: (bounds.left - parentBounds.left) * (1 / k),
y: (bounds.top - parentBounds.top) * (1 / k),
...dimensions
};
});
);
};
const onStart = (
evt: MouseEvent, onClick: (node: Node) => void, id: ElementId, type: string,
data: any, setOffset: (pos: XYPosition) => void, transform: Transform, position: XYPosition
): false | void => {
evt: MouseEvent,
onClick: (node: Node) => void,
id: ElementId,
type: string,
data: any,
setOffset: (pos: XYPosition) => void,
transform: Transform,
position: XYPosition
): false | void => {
if (isInputDOMNode(evt) || isHandle(evt)) {
return false;
}
const scaledClient: XYPosition = {
x: evt.clientX * (1 / transform[2]),
y: evt.clientY * (1 / transform[2])
y: evt.clientY * (1 / transform[2]),
};
const offsetX = scaledClient.x - position.x - transform[0];
const offsetY = scaledClient.y - position.y - transform[1];
const node = { id, type, position, data };
const node = { id, type, position, data };
store.dispatch.setSelectedElements({ id, type });
store.dispatch.setSelectedElements({ id, type } as Node);
setOffset({ x: offsetX, y: offsetY });
onClick(node);
};
const onDrag = (
evt: MouseEvent, setDragging: (isDragging: boolean) => void, id: ElementId, offset: XYPosition,
evt: MouseEvent,
setDragging: (isDragging: boolean) => void,
id: ElementId,
offset: XYPosition,
transform: Transform
): void => {
const scaledClient = {
x: evt.clientX * (1 / transform[2]),
y: evt.clientY * (1 / transform[2])
y: evt.clientY * (1 / transform[2]),
};
setDragging(true);
store.dispatch.updateNodePos({ id, pos: {
x: scaledClient.x - transform[0] - offset.x,
y: scaledClient.y - transform[1] - offset.y
}});
store.dispatch.updateNodePos({
id,
pos: {
x: scaledClient.x - transform[0] - offset.x,
y: scaledClient.y - transform[1] - offset.y,
},
});
};
const onStop = (
onNodeDragStop: (params: Node) => void, isDragging: boolean, setDragging: (isDragging: boolean) => void, id: ElementId,
type: string, position: XYPosition, data: any
onNodeDragStop: (params: Node) => void,
isDragging: boolean,
setDragging: (isDragging: boolean) => void,
id: ElementId,
type: string,
position: XYPosition,
data: any
): void => {
if (isDragging) {
setDragging(false);
onNodeDragStop({
id, type, position, data
});
id,
type,
position,
data,
} as Node);
}
};
export default (NodeComponent: ComponentType<NodeComponentProps>) => {
const NodeWrapper = memo(({
id, type, data, transform,
xPos, yPos, selected, onClick,
onNodeDragStop, style
}: NodeComponentProps) => {
const nodeElement = useRef<HTMLDivElement>(null);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setDragging] = useState(false);
const NodeWrapper = memo(
({
id,
type,
data,
transform,
xPos,
yPos,
selected,
onClick,
onNodeDragStop,
style,
}: WrapNodeProps) => {
const nodeElement = useRef<HTMLDivElement>(null);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setDragging] = useState(false);
const position = { x: xPos, y: yPos };
const nodeClasses = cx('react-flow__node', { selected });
const nodeStyle = { zIndex: selected ? 10 : 3, transform: `translate(${xPos}px,${yPos}px)` };
const updateNode = () => {
if (!nodeElement.current) {
return false;
}
const storeState = store.getState()
const bounds = nodeElement.current.getBoundingClientRect();
const dimensions = getDimensions(nodeElement.current);
const handleBounds = {
source: getHandleBounds('.source', nodeElement.current, bounds, storeState.transform[2]),
target: getHandleBounds('.target', nodeElement.current, bounds, storeState.transform[2])
const position = { x: xPos, y: yPos };
const nodeClasses = cx('react-flow__node', { selected });
const nodeStyle = {
zIndex: selected ? 10 : 3,
transform: `translate(${xPos}px,${yPos}px)`,
};
store.dispatch.updateNodeData({ id, ...dimensions, handleBounds });
};
useEffect(() => {
if (nodeElement.current) {
updateNode();
const resizeObserver = new ResizeObserver(entries => {
for (let _ of entries) {
updateNode();
}
});
resizeObserver.observe(nodeElement.current);
return () => {
if (resizeObserver && nodeElement.current) {
resizeObserver.unobserve(nodeElement.current);
}
const updateNode = (): void => {
if (!nodeElement.current) {
return;
}
}
}, [nodeElement.current]);
return (
<DraggableCore
onStart={evt => onStart(evt as MouseEvent, onClick, id, type, data, setOffset, transform, position)}
onDrag={evt => onDrag(evt as MouseEvent, setDragging, id, offset, transform)}
onStop={() => onStop(onNodeDragStop, isDragging, setDragging, id, type, position, data)}
scale={transform[2]}
>
<div
className={nodeClasses}
ref={nodeElement}
style={nodeStyle}
const storeState = store.getState();
const bounds = nodeElement.current.getBoundingClientRect();
const dimensions = getDimensions(nodeElement.current);
const handleBounds = {
source: getHandleBounds(
'.source',
nodeElement.current,
bounds,
storeState.transform[2]
),
target: getHandleBounds(
'.target',
nodeElement.current,
bounds,
storeState.transform[2]
),
};
store.dispatch.updateNodeData({ id, ...dimensions, handleBounds });
};
useEffect(() => {
if (nodeElement.current) {
updateNode();
const resizeObserver = new ResizeObserver(entries => {
for (let _ of entries) {
updateNode();
}
});
resizeObserver.observe(nodeElement.current);
return () => {
if (resizeObserver && nodeElement.current) {
resizeObserver.unobserve(nodeElement.current);
}
};
}
return;
}, [nodeElement.current]);
return (
<DraggableCore
onStart={evt =>
onStart(
evt as MouseEvent,
onClick,
id,
type,
data,
setOffset,
transform,
position
)
}
onDrag={evt =>
onDrag(evt as MouseEvent, setDragging, id, offset, transform)
}
onStop={() =>
onStop(
onNodeDragStop,
isDragging,
setDragging,
id,
type,
position,
data
)
}
scale={transform[2]}
>
<Provider value={id}>
<NodeComponent
id={id}
data={data}
type={type}
style={style}
selected={selected}
/>
</Provider>
</div>
</DraggableCore>
);
});
<div className={nodeClasses} ref={nodeElement} style={nodeStyle}>
<Provider value={id}>
<NodeComponent
id={id}
data={data}
type={type}
style={style}
selected={selected}
/>
</Provider>
</div>
</DraggableCore>
);
}
);
NodeWrapper.displayName = 'NodeWrapper';
+45 -31
View File
@@ -5,28 +5,30 @@ import { useStoreState, useStoreActions } from '../../store/hooks';
import { isNode } from '../../utils/graph';
import { Node, Elements, XYPosition } from '../../types';
function getStartPositions(elements: Elements) {
return elements
.filter(isNode)
.reduce((res, node: Node) => {
const startPosition = {
x: node.__rg.position.x || node.position.x,
y: node.__rg.position.y || node.position.x
};
type StartPositions = { [key: string]: XYPosition };
res[node.id] = startPosition;
function getStartPositions(elements: Elements): StartPositions {
const startPositions: StartPositions = {};
return res;
}, {});
return (elements.filter(isNode) as Node[]).reduce((res, node) => {
const startPosition = {
x: node.__rg.position.x || node.position.x,
y: node.__rg.position.y || node.position.x,
};
res[node.id] = startPosition;
return res;
}, startPositions);
}
export default memo(() => {
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [startPositions, setStartPositions] = useState({});
const [offset, setOffset] = useState<XYPosition>({ x: 0, y: 0 });
const [startPositions, setStartPositions] = useState<StartPositions>({});
const state = useStoreState(s => ({
transform: s.transform,
selectedNodesBbox: s.selectedNodesBbox,
selectedElements: s.selectedElements
selectedElements: s.selectedElements,
}));
const updateNodePos = useStoreActions(a => a.updateNodePos);
const [x, y, k] = state.transform;
@@ -35,43 +37,55 @@ export default memo(() => {
const onStart = (evt: MouseEvent) => {
const scaledClient: XYPosition = {
x: evt.clientX * (1 / k),
y: evt.clientY * (1 / k)
y: evt.clientY * (1 / k),
};
const offsetX: number = scaledClient.x - position.x - x;
const offsetY: number = scaledClient.y - position.y - y;
const startPositions = getStartPositions(state.selectedElements);
const nextStartPositions = getStartPositions(state.selectedElements);
setOffset({ x: offsetX, y: offsetY });
setStartPositions(startPositions);
if (nextStartPositions) {
setOffset({ x: offsetX, y: offsetY });
setStartPositions(nextStartPositions);
}
};
const onDrag = (evt: MouseEvent) => {
const scaledClient: XYPosition = {
x: evt.clientX * (1 / k),
y: evt.clientY * (1 / k)
y: evt.clientY * (1 / k),
};
state.selectedElements
.filter(isNode)
.forEach((node: Node) => {
updateNodePos({ id: node.id, pos: {
x: startPositions[node.id].x + scaledClient.x - position.x - offset.x - x ,
y: startPositions[node.id].y + scaledClient.y - position.y - offset.y - y
}});
});
(state.selectedElements.filter(isNode) as Node[]).forEach(node => {
const pos: XYPosition = {
x:
startPositions[node.id].x +
scaledClient.x -
position.x -
offset.x -
x,
y:
startPositions[node.id].y +
scaledClient.y -
position.y -
offset.y -
y,
};
updateNodePos({ id: node.id, pos });
});
};
return (
<div
className="react-flow__nodesselection"
style={{
transform: `translate(${x}px,${y}px) scale(${k})`
transform: `translate(${x}px,${y}px) scale(${k})`,
}}
>
<ReactDraggable
scale={k}
onStart={(evt: MouseEvent) => onStart(evt)}
onDrag={(evt: MouseEvent) => onDrag(evt)}
onStart={evt => onStart(evt as MouseEvent)}
onDrag={evt => onDrag(evt as MouseEvent)}
>
<div
className="react-flow__nodesselection-rect"
@@ -79,7 +93,7 @@ export default memo(() => {
width: state.selectedNodesBbox.width,
height: state.selectedNodesBbox.height,
top: state.selectedNodesBbox.y,
left: state.selectedNodesBbox.x
left: state.selectedNodesBbox.x,
}}
/>
</ReactDraggable>
+35 -27
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, memo, MouseEvent } from 'react';
import React, { useEffect, useRef, useState, memo } from 'react';
import { useStoreActions } from '../../store/hooks';
import { SelectionRect } from '../../types';
@@ -10,7 +10,7 @@ const initialRect: SelectionRect = {
y: 0,
width: 0,
height: 0,
draw: false
draw: false,
};
function getMousePosition(evt: MouseEvent) {
@@ -28,33 +28,33 @@ function getMousePosition(evt: MouseEvent) {
}
export default memo(() => {
const selectionPane = useRef(null);
const selectionPane = useRef<HTMLDivElement>(null);
const [rect, setRect] = useState(initialRect);
const setSelection = useStoreActions(a => a.setSelection);
const updateSelection = useStoreActions(a => a.updateSelection);
const setNodesSelection = useStoreActions(a => a.setNodesSelection);
useEffect(() => {
function onMouseDown(evt: MouseEvent) {
function onMouseDown(evt: MouseEvent): void {
const mousePos = getMousePosition(evt);
if (!mousePos) {
return false;
return;
}
setRect((currentRect) => ({
setRect(currentRect => ({
...currentRect,
startX: mousePos.x,
startY: mousePos.y,
x: mousePos.x,
y: mousePos.y,
draw: true
draw: true,
}));
setSelection(true);
}
function onMouseMove(evt: MouseEvent) {
setRect((currentRect) => {
function onMouseMove(evt: MouseEvent): void {
setRect(currentRect => {
if (!currentRect.draw) {
return currentRect;
}
@@ -70,8 +70,12 @@ export default memo(() => {
...currentRect,
x: negativeX ? mousePos.x : currentRect.x,
y: negativeY ? mousePos.y : currentRect.y,
width: negativeX ? currentRect.startX - mousePos.x : mousePos.x - currentRect.startX,
height: negativeY ? currentRect.startY - mousePos.y : mousePos.y - currentRect.startY,
width: negativeX
? currentRect.startX - mousePos.x
: mousePos.x - currentRect.startX,
height: negativeY
? currentRect.startY - mousePos.y
: mousePos.y - currentRect.startY,
};
updateSelection(nextRect);
@@ -81,40 +85,44 @@ export default memo(() => {
}
function onMouseUp() {
setRect((currentRect) => {
setRect(currentRect => {
setNodesSelection({ isActive: true, selection: currentRect });
setSelection(false);
return {
...currentRect,
draw: false
draw: false,
};
});
}
selectionPane.current.addEventListener('mousedown', onMouseDown);
selectionPane.current.addEventListener('mousemove', onMouseMove);
selectionPane.current.addEventListener('mouseup', onMouseUp);
if (selectionPane.current) {
selectionPane.current.addEventListener('mousedown', onMouseDown);
selectionPane.current.addEventListener('mousemove', onMouseMove);
selectionPane.current.addEventListener('mouseup', onMouseUp);
return () => {
selectionPane.current.removeEventListener('mousedown', onMouseDown);
selectionPane.current.removeEventListener('mousemove', onMouseMove);
selectionPane.current.removeEventListener('mouseup', onMouseUp);
};
}, []);
return () => {
if (!selectionPane.current) {
return;
}
selectionPane.current.removeEventListener('mousedown', onMouseDown);
selectionPane.current.removeEventListener('mousemove', onMouseMove);
selectionPane.current.removeEventListener('mouseup', onMouseUp);
};
}
return;
}, [selectionPane.current]);
return (
<div
className="react-flow__selectionpane"
ref={selectionPane}
>
<div className="react-flow__selectionpane" ref={selectionPane}>
{rect.draw && (
<div
className="react-flow__selection"
style={{
width: rect.width,
height: rect.height,
transform: `translate(${rect.x}px, ${rect.y}px)`
transform: `translate(${rect.x}px, ${rect.y}px)`,
}}
/>
)}
+156 -92
View File
@@ -3,7 +3,15 @@ import React, { memo, SVGAttributes } from 'react';
import { useStoreState } from '../../store/hooks';
import ConnectionLine from '../../components/ConnectionLine/index';
import { isEdge } from '../../utils/graph';
import { XYPosition, Position, Edge, Node, ElementId, Transform, HandleElement } from '../../types';
import {
XYPosition,
Position,
Edge,
Node,
ElementId,
Transform,
HandleElement,
} from '../../types';
interface EdgeRendererProps {
width: number;
@@ -12,69 +20,82 @@ interface EdgeRendererProps {
connectionLineStyle?: SVGAttributes<{}>;
connectionLineType?: string;
onElementClick?: () => void;
};
}
interface EdgeRendererState {
nodes: Node[];
edges: Edge[];
transform: Transform;
selectedElements: any;
connectionSourceId: ElementId | null;
connectionSourceId: ElementId | null;
position: XYPosition;
};
}
interface EdgePositions {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
};
}
function getHandlePosition(position: Position, node: Node, handle: any | null = null): XYPosition {
function getHandlePosition(
position: Position,
node: Node,
handle: any | null = null
): XYPosition {
if (!handle) {
switch (position) {
case 'top': return {
x: node.__rg.width / 2,
y: 0
};
case 'right': return {
x: node.__rg.width,
y: node.__rg.height / 2
};
case 'bottom': return {
x: node.__rg.width / 2,
y: node.__rg.height
};
case 'left': return {
x: 0,
y: node.__rg.height / 2
};
case 'top':
return {
x: node.__rg.width / 2,
y: 0,
};
case 'right':
return {
x: node.__rg.width,
y: node.__rg.height / 2,
};
case 'bottom':
return {
x: node.__rg.width / 2,
y: node.__rg.height,
};
case 'left':
return {
x: 0,
y: node.__rg.height / 2,
};
}
return null;
}
switch (position) {
case 'top': return {
x: handle.x + (handle.width / 2),
y: handle.y
};
case 'right': return {
x: handle.x + handle.width,
y: handle.y + (handle.height / 2)
};
case 'bottom': return {
x: handle.x + (handle.width / 2),
y: handle.y + handle.height
};
case 'left': return {
x: handle.x,
y: handle.y + (handle.height / 2)
};
case 'top':
return {
x: handle.x + handle.width / 2,
y: handle.y,
};
case 'right':
return {
x: handle.x + handle.width,
y: handle.y + handle.height / 2,
};
case 'bottom':
return {
x: handle.x + handle.width / 2,
y: handle.y + handle.height,
};
case 'left':
return {
x: handle.x,
y: handle.y + handle.height / 2,
};
}
}
function getHandle(bounds: HandleElement[], handleId: ElementId): HandleElement | null {
function getHandle(
bounds: HandleElement[],
handleId: ElementId | null
): HandleElement | null | undefined {
let handle = null;
if (!bounds) {
@@ -83,7 +104,7 @@ function getHandle(bounds: HandleElement[], handleId: ElementId): HandleElement
// there is no handleId when there are no multiple handles/ handles with ids
// so we just pick the first one
if (bounds.length === 1 || !handleId) {
if (bounds.length === 1 || !handleId) {
handle = bounds[0];
} else if (handleId) {
handle = bounds.find(d => d.id === handleId);
@@ -93,23 +114,42 @@ function getHandle(bounds: HandleElement[], handleId: ElementId): HandleElement
}
function getEdgePositions(
sourceNode: Node, sourceHandle: HandleElement, sourcePosition: Position,
targetNode: Node, targetHandle: HandleElement, targetPosition: Position
sourceNode: Node,
sourceHandle: HandleElement | unknown,
sourcePosition: Position,
targetNode: Node,
targetHandle: HandleElement | unknown,
targetPosition: Position
): EdgePositions {
const sourceHandlePos = getHandlePosition(sourcePosition, sourceNode, sourceHandle)
const sourceHandlePos = getHandlePosition(
sourcePosition,
sourceNode,
sourceHandle
);
const sourceX = sourceNode.__rg.position.x + sourceHandlePos.x;
const sourceY = sourceNode.__rg.position.y + sourceHandlePos.y;
const targetHandlePos = getHandlePosition(targetPosition, targetNode, targetHandle);
const targetHandlePos = getHandlePosition(
targetPosition,
targetNode,
targetHandle
);
const targetX = targetNode.__rg.position.x + targetHandlePos.x;
const targetY = targetNode.__rg.position.y + targetHandlePos.y;
return {
sourceX, sourceY, targetX, targetY
sourceX,
sourceY,
targetX,
targetY,
};
}
function renderEdge(edge: Edge, props: EdgeRendererProps, state: EdgeRendererState) {
function renderEdge(
edge: Edge,
props: EdgeRendererProps,
state: EdgeRendererState
) {
const edgeType = edge.type || 'default';
const hasSourceHandleId = edge.source.includes('__');
@@ -133,14 +173,24 @@ function renderEdge(edge: Edge, props: EdgeRendererProps, state: EdgeRendererSta
}
const EdgeComponent = props.edgeTypes[edgeType] || props.edgeTypes.default;
const sourceHandle = getHandle(sourceNode.__rg.handleBounds.source, sourceHandleId);
const targetHandle = getHandle(targetNode.__rg.handleBounds.target, targetHandleId);
const sourceHandle = getHandle(
sourceNode.__rg.handleBounds.source,
sourceHandleId
);
const targetHandle = getHandle(
targetNode.__rg.handleBounds.target,
targetHandleId
);
const sourcePosition = sourceHandle ? sourceHandle.position : 'bottom';
const targetPosition = targetHandle ? targetHandle.position : 'top';
const { sourceX, sourceY, targetX, targetY } = getEdgePositions(
sourceNode, sourceHandle, sourcePosition,
targetNode, targetHandle, targetPosition
sourceNode,
sourceHandle,
sourcePosition,
targetNode,
targetHandle,
targetPosition
);
const selected = state.selectedElements
.filter(isEdge)
@@ -169,47 +219,61 @@ function renderEdge(edge: Edge, props: EdgeRendererProps, state: EdgeRendererSta
);
}
const EdgeRenderer = memo(({
width, height, connectionLineStyle, connectionLineType, ...rest
}: EdgeRendererProps) => {
const state: EdgeRendererState = useStoreState(s => ({
nodes: s.nodes,
edges: s.edges,
transform: s.transform,
selectedElements: s.selectedElements,
connectionSourceId: s.connectionSourceId,
position: s.connectionPosition
}));
if (!width) {
return null;
const EdgeRenderer = memo(
({
width,
height,
connectionLineStyle,
connectionLineType,
...rest
}: EdgeRendererProps) => {
const state: EdgeRendererState = useStoreState(s => ({
nodes: s.nodes,
edges: s.edges,
transform: s.transform,
selectedElements: s.selectedElements,
connectionSourceId: s.connectionSourceId,
position: s.connectionPosition,
}));
if (!width) {
return null;
}
const { transform, edges, nodes, connectionSourceId, position } = state;
const transformStyle = `translate(${transform[0]},${transform[1]}) scale(${transform[2]})`;
return (
<svg width={width} height={height} className="react-flow__edges">
<g transform={transformStyle}>
{edges.map((e: Edge) =>
renderEdge(
e,
{
width,
height,
connectionLineStyle,
connectionLineType,
...rest,
},
state
)
)}
{connectionSourceId && (
<ConnectionLine
nodes={nodes}
connectionSourceId={connectionSourceId}
connectionPositionX={position.x}
connectionPositionY={position.y}
transform={transform}
connectionLineStyle={connectionLineStyle}
connectionLineType={connectionLineType}
/>
)}
</g>
</svg>
);
}
const { transform, edges, nodes, connectionSourceId, position } = state;
const transformStyle = `translate(${transform[0]},${transform[1]}) scale(${transform[2]})`;
return (
<svg
width={width}
height={height}
className="react-flow__edges"
>
<g transform={transformStyle}>
{edges.map((e: Edge) => renderEdge(e, { width, height, connectionLineStyle, connectionLineType, ...rest }, state))}
{connectionSourceId && (
<ConnectionLine
nodes={nodes}
connectionSourceId={connectionSourceId}
connectionPositionX={position.x}
connectionPositionY={position.y}
transform={transform}
connectionLineStyle={connectionLineStyle}
connectionLineType={connectionLineType}
/>
)}
</g>
</svg>
);
});
);
EdgeRenderer.displayName = 'EdgeRenderer';
+15 -9
View File
@@ -4,25 +4,31 @@ import StraightEdge from '../../components/Edges/StraightEdge';
import BezierEdge from '../../components/Edges/BezierEdge';
import wrapEdge from '../../components/Edges/wrapEdge';
import { EdgeTypesType, EdgeWrapperProps } from '../../types';
import { EdgeTypesType, EdgeCompProps } from '../../types';
export function createEdgeTypes(edgeTypes: EdgeTypesType): EdgeTypesType{
export function createEdgeTypes(edgeTypes: EdgeTypesType): EdgeTypesType {
const standardTypes: EdgeTypesType = {
default: wrapEdge((edgeTypes.default || BezierEdge) as ComponentType<EdgeWrapperProps>),
straight: wrapEdge((edgeTypes.bezier || StraightEdge) as ComponentType<EdgeWrapperProps>)
default: wrapEdge((edgeTypes.default || BezierEdge) as ComponentType<
EdgeCompProps
>),
straight: wrapEdge((edgeTypes.bezier || StraightEdge) as ComponentType<
EdgeCompProps
>),
};
const specialTypes: EdgeTypesType = Object
.keys(edgeTypes)
const wrappedTypes = {} as EdgeTypesType;
const specialTypes: EdgeTypesType = Object.keys(edgeTypes)
.filter(k => !['default', 'bezier'].includes(k))
.reduce((res, key) => {
res[key] = wrapEdge((edgeTypes[key] || BezierEdge) as ComponentType<EdgeWrapperProps>);
res[key] = wrapEdge((edgeTypes[key] || BezierEdge) as ComponentType<
EdgeCompProps
>);
return res;
}, {});
}, wrappedTypes);
return {
...standardTypes,
...specialTypes
...specialTypes,
};
}
+132 -96
View File
@@ -9,117 +9,153 @@ import BackgroundGrid from '../../components/BackgroundGrid';
import useKeyPress from '../../hooks/useKeyPress';
import useD3Zoom from '../../hooks/useD3Zoom';
import useGlobalKeyHandler from '../../hooks/useGlobalKeyHandler';
import useElementUpdater from '../../hooks/useElementUpdater'
import useElementUpdater from '../../hooks/useElementUpdater';
import { getDimensions } from '../../utils';
import { fitView, zoomIn, zoomOut } from '../../utils/graph';
import { Elements, NodeTypesType, EdgeTypesType, GridType, OnLoadFunc } from '../../types'
import {
Elements,
NodeTypesType,
EdgeTypesType,
GridType,
OnLoadFunc,
} from '../../types';
export interface GraphViewProps {
elements: Elements,
onElementClick: () => void,
onElementsRemove: (elements: Elements) => void,
onNodeDragStop: () => void,
onConnect: () => void,
onLoad: OnLoadFunc,
onMove: () => void,
selectionKeyCode: number,
nodeTypes: NodeTypesType,
edgeTypes: EdgeTypesType,
connectionLineType: string,
connectionLineStyle: SVGAttributes<{}>,
deleteKeyCode: number,
showBackground: boolean,
backgroundGap: number,
backgroundColor: string,
backgroundType: GridType,
};
elements: Elements;
onElementClick: () => void;
onElementsRemove: (elements: Elements) => void;
onNodeDragStop: () => void;
onConnect: () => void;
onLoad: OnLoadFunc;
onMove: () => void;
selectionKeyCode: number;
nodeTypes: NodeTypesType;
edgeTypes: EdgeTypesType;
connectionLineType: string;
connectionLineStyle: SVGAttributes<{}>;
deleteKeyCode: number;
showBackground: boolean;
backgroundGap: number;
backgroundColor: string;
backgroundType: GridType;
snapToGrid: boolean;
snapGrid: [number, number];
}
const GraphView = memo(({
nodeTypes, edgeTypes, onMove, onLoad,
onElementClick, onNodeDragStop, connectionLineType, connectionLineStyle,
selectionKeyCode, onElementsRemove, deleteKeyCode, elements,
showBackground, backgroundGap, backgroundColor, backgroundType,
onConnect
}: GraphViewProps) => {
const zoomPane = useRef<HTMLDivElement>(null);
const rendererNode = useRef<HTMLDivElement>(null);
const state = useStoreState(s => ({
width: s.width,
height: s.height,
nodes: s.nodes,
edges: s.edges,
d3Initialised: s.d3Initialised,
nodesSelectionActive: s.nodesSelectionActive
}));
const updateSize = useStoreActions(actions => actions.updateSize);
const setNodesSelection = useStoreActions(actions => actions.setNodesSelection);
const setOnConnect = useStoreActions(a => a.setOnConnect);
const selectionKeyPressed = useKeyPress(selectionKeyCode);
const GraphView = memo(
({
nodeTypes,
edgeTypes,
onMove,
onLoad,
onElementClick,
onNodeDragStop,
connectionLineType,
connectionLineStyle,
selectionKeyCode,
onElementsRemove,
deleteKeyCode,
elements,
showBackground,
backgroundGap,
backgroundColor,
backgroundType,
onConnect,
snapToGrid,
snapGrid,
}: GraphViewProps) => {
const zoomPane = useRef<HTMLDivElement>(null);
const rendererNode = useRef<HTMLDivElement>(null);
const state = useStoreState(s => ({
width: s.width,
height: s.height,
nodes: s.nodes,
edges: s.edges,
d3Initialised: s.d3Initialised,
nodesSelectionActive: s.nodesSelectionActive,
}));
const updateSize = useStoreActions(actions => actions.updateSize);
const setNodesSelection = useStoreActions(
actions => actions.setNodesSelection
);
const setOnConnect = useStoreActions(a => a.setOnConnect);
const setSnapGrid = useStoreActions(actions => actions.setSnapGrid);
const onZoomPaneClick = () => setNodesSelection({ isActive: false });
const selectionKeyPressed = useKeyPress(selectionKeyCode);
const updateDimensions = () => {
const size = getDimensions(rendererNode.current);
updateSize(size);
};
const onZoomPaneClick = () => setNodesSelection({ isActive: false });
useEffect(() => {
updateDimensions();
setOnConnect(onConnect);
window.onresize = updateDimensions;
const updateDimensions = () => {
if (!rendererNode.current) {
return;
}
return () => {
window.onresize = null;
const size = getDimensions(rendererNode.current);
updateSize(size);
};
}, []);
useD3Zoom(zoomPane, onMove, selectionKeyPressed);
useEffect(() => {
updateDimensions();
setOnConnect(onConnect);
window.onresize = updateDimensions;
useEffect(() => {
if (state.d3Initialised) {
onLoad({
fitView,
zoomIn,
zoomOut
});
}
}, [state.d3Initialised]);
return () => {
window.onresize = null;
};
}, []);
useGlobalKeyHandler({ onElementsRemove, deleteKeyCode });
useElementUpdater(elements);
useD3Zoom(zoomPane, onMove, selectionKeyPressed);
return (
<div className="react-flow__renderer" ref={rendererNode}>
{showBackground && (
<BackgroundGrid
gap={backgroundGap}
color={backgroundColor}
backgroundType={backgroundType}
useEffect(() => {
if (state.d3Initialised) {
onLoad({
fitView,
zoomIn,
zoomOut,
});
}
}, [state.d3Initialised]);
useEffect(() => {
setSnapGrid({ snapToGrid, snapGrid });
}, [snapToGrid]);
useGlobalKeyHandler({ onElementsRemove, deleteKeyCode });
useElementUpdater(elements);
return (
<div className="react-flow__renderer" ref={rendererNode}>
{showBackground && (
<BackgroundGrid
gap={backgroundGap}
color={backgroundColor}
backgroundType={backgroundType}
/>
)}
<NodeRenderer
nodeTypes={nodeTypes}
onElementClick={onElementClick}
onNodeDragStop={onNodeDragStop}
/>
)}
<NodeRenderer
nodeTypes={nodeTypes}
onElementClick={onElementClick}
onNodeDragStop={onNodeDragStop}
/>
<EdgeRenderer
width={state.width}
height={state.height}
edgeTypes={edgeTypes}
onElementClick={onElementClick}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
/>
{selectionKeyPressed && <UserSelection />}
{state.nodesSelectionActive && <NodesSelection />}
<div
className="react-flow__zoompane"
onClick={onZoomPaneClick}
ref={zoomPane}
/>
</div>
);
});
<EdgeRenderer
width={state.width}
height={state.height}
edgeTypes={edgeTypes}
onElementClick={onElementClick}
connectionLineType={connectionLineType}
connectionLineStyle={connectionLineStyle}
/>
{selectionKeyPressed && <UserSelection />}
{state.nodesSelectionActive && <NodesSelection />}
<div
className="react-flow__zoompane"
onClick={onZoomPaneClick}
ref={zoomPane}
/>
</div>
);
}
);
GraphView.displayName = 'GraphView';
+24 -13
View File
@@ -2,28 +2,40 @@ import React, { memo, ComponentType } from 'react';
import { useStoreState } from '../../store/hooks';
import { isNode } from '../../utils/graph';
import { Node, Transform, NodeTypesType, NodeComponentProps, } from '../../types';
import {
Node,
Transform,
NodeTypesType,
NodeComponentProps,
} from '../../types';
interface NodeRendererProps {
nodeTypes: NodeTypesType;
onElementClick: () => void;
onNodeDragStop: () => void;
};
}
interface NodeRendererState {
nodes: Node[];
transform: Transform;
selectedElements: any;
};
}
function renderNode(node: Node, props: NodeRendererProps, state: NodeRendererState) {
function renderNode(
node: Node,
props: NodeRendererProps,
state: NodeRendererState
) {
const nodeType = node.type || 'default';
if (!props.nodeTypes[nodeType]) {
console.warn(`No node type found for type "${nodeType}". Using fallback type "default".`);
console.warn(
`No node type found for type "${nodeType}". Using fallback type "default".`
);
}
const NodeComponent = (props.nodeTypes[nodeType] || props.nodeTypes.default) as ComponentType<NodeComponentProps>;
const NodeComponent = (props.nodeTypes[nodeType] ||
props.nodeTypes.default) as ComponentType<NodeComponentProps>;
const selected = state.selectedElements
.filter(isNode)
.map((e: Node) => e.id)
@@ -33,7 +45,7 @@ function renderNode(node: Node, props: NodeRendererProps, state: NodeRendererSta
<NodeComponent
key={node.id}
id={node.id}
type={node.type}
type={nodeType}
data={node.data}
xPos={node.__rg.position.x}
yPos={node.__rg.position.y}
@@ -50,17 +62,16 @@ const NodeRenderer = memo((props: NodeRendererProps) => {
const state: NodeRendererState = useStoreState(s => ({
nodes: s.nodes,
transform: s.transform,
selectedElements: s.selectedElements
selectedElements: s.selectedElements,
}));
const { transform, nodes } = state;
const transformStyle = { transform : `translate(${transform[0]}px,${transform[1]}px) scale(${transform[2]})` };
const transformStyle = {
transform: `translate(${transform[0]}px,${transform[1]}px) scale(${transform[2]})`,
};
return (
<div
className="react-flow__nodes"
style={transformStyle}
>
<div className="react-flow__nodes" style={transformStyle}>
{nodes.map(node => renderNode(node, props, state))}
</div>
);
+17 -9
View File
@@ -6,24 +6,32 @@ import OutputNode from '../../components/Nodes/OutputNode';
import wrapNode from '../../components/Nodes/wrapNode';
import { NodeTypesType, NodeComponentProps } from '../../types';
export function createNodeTypes(nodeTypes: NodeTypesType): NodeTypesType {
export function createNodeTypes(nodeTypes: NodeTypesType): NodeTypesType {
const standardTypes: NodeTypesType = {
input: wrapNode((nodeTypes.input || InputNode) as ComponentType<NodeComponentProps>),
default: wrapNode((nodeTypes.default || DefaultNode) as ComponentType<NodeComponentProps>),
output: wrapNode((nodeTypes.output || OutputNode) as ComponentType<NodeComponentProps>)
input: wrapNode((nodeTypes.input || InputNode) as ComponentType<
NodeComponentProps
>),
default: wrapNode((nodeTypes.default || DefaultNode) as ComponentType<
NodeComponentProps
>),
output: wrapNode((nodeTypes.output || OutputNode) as ComponentType<
NodeComponentProps
>),
};
const specialTypes: NodeTypesType = Object
.keys(nodeTypes)
const wrappedTypes = {} as NodeTypesType;
const specialTypes: NodeTypesType = Object.keys(nodeTypes)
.filter(k => !['input', 'default', 'output'].includes(k))
.reduce((res, key) => {
res[key] = wrapNode((nodeTypes[key] || DefaultNode) as ComponentType<NodeComponentProps>);
res[key] = wrapNode((nodeTypes[key] || DefaultNode) as ComponentType<
NodeComponentProps
>);
return res;
}, {});
}, wrappedTypes);
return {
...standardTypes,
...specialTypes
...specialTypes,
};
}
+60 -34
View File
@@ -1,7 +1,7 @@
import React, { useMemo, CSSProperties, ReactNode, SVGAttributes } from 'react';
import React, { useMemo, SVGAttributes, HTMLAttributes } from 'react';
import { StoreProvider } from 'easy-peasy';
const nodeEnv: string = (process.env.NODE_ENV as string);
const nodeEnv: string = process.env.NODE_ENV as string;
if (nodeEnv !== 'production') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
@@ -18,39 +18,61 @@ import StraightEdge from '../../components/Edges/StraightEdge';
import StepEdge from '../../components/Edges/StepEdge';
import { createEdgeTypes } from '../EdgeRenderer/utils';
import store from '../../store';
import { Elements, NodeTypesType, EdgeTypesType, GridType, OnLoadFunc } from '../../types';
import {
Elements,
NodeTypesType,
EdgeTypesType,
GridType,
OnLoadFunc,
} from '../../types';
import '../../style.css';
export interface ReactFlowProps {
elements: Elements,
style?: CSSProperties,
className?: string,
children?: ReactNode[],
onElementClick: () => void,
onElementsRemove: (elements: Elements) => void,
onNodeDragStop: () => void,
onConnect: () => void,
onLoad: OnLoadFunc,
onMove: () => void,
nodeTypes: NodeTypesType,
edgeTypes: EdgeTypesType,
connectionLineType: string,
connectionLineStyle: SVGAttributes<{}>,
deleteKeyCode: number,
selectionKeyCode: number,
showBackground: boolean,
backgroundGap: number,
backgroundColor: string,
backgroundType: GridType
};
export interface ReactFlowProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onLoad'> {
elements: Elements;
onElementClick: () => void;
onElementsRemove: (elements: Elements) => void;
onNodeDragStop: () => void;
onConnect: () => void;
onLoad: OnLoadFunc;
onMove: () => void;
nodeTypes: NodeTypesType;
edgeTypes: EdgeTypesType;
connectionLineType: string;
connectionLineStyle: SVGAttributes<{}>;
deleteKeyCode: number;
selectionKeyCode: number;
showBackground: boolean;
backgroundGap: number;
backgroundColor: string;
backgroundType: GridType;
snapToGrid: boolean;
snapGrid: [16, 16];
}
const ReactFlow = ({
style, onElementClick, elements, children,
nodeTypes, edgeTypes, onLoad, onMove,
onElementsRemove, onConnect, onNodeDragStop, connectionLineType,
connectionLineStyle, deleteKeyCode, selectionKeyCode,
showBackground, backgroundGap, backgroundType, backgroundColor
style,
onElementClick,
elements,
children,
nodeTypes,
edgeTypes,
onLoad,
onMove,
onElementsRemove,
onConnect,
onNodeDragStop,
connectionLineType,
connectionLineStyle,
deleteKeyCode,
selectionKeyCode,
showBackground,
backgroundGap,
backgroundType,
backgroundColor,
snapToGrid,
snapGrid,
}: ReactFlowProps) => {
const nodeTypesParsed = useMemo(() => createNodeTypes(nodeTypes), []);
const edgeTypesParsed = useMemo(() => createEdgeTypes(edgeTypes), []);
@@ -76,6 +98,8 @@ const ReactFlow = ({
backgroundGap={backgroundGap}
showBackground={showBackground}
backgroundType={backgroundType}
snapToGrid={snapToGrid}
snapGrid={snapGrid}
/>
{children}
</StoreProvider>
@@ -90,17 +114,17 @@ ReactFlow.defaultProps = {
onElementsRemove: () => {},
onNodeDragStop: () => {},
onConnect: () => {},
onLoad: () => {},
onLoad: () => {},
onMove: () => {},
nodeTypes: {
input: InputNode,
default: DefaultNode,
output: OutputNode
output: OutputNode,
},
edgeTypes: {
default: BezierEdge,
straight: StraightEdge,
step: StepEdge
step: StepEdge,
},
connectionLineType: 'bezier',
connectionLineStyle: {},
@@ -109,7 +133,9 @@ ReactFlow.defaultProps = {
backgroundColor: '#eee',
backgroundGap: 24,
showBackground: true,
backgroundType: GridType.Dots
backgroundType: GridType.Dots,
snapToGrid: false,
snapGrid: [16, 16],
};
export default ReactFlow;
+1 -1
View File
@@ -2,7 +2,7 @@ import { createContext } from 'react';
import { ElementId } from '../types';
type ContextProps = ElementId | null;
type ContextProps = ElementId | null;
export const NodeIdContext = createContext<Partial<ContextProps>>(null);
export const Provider = NodeIdContext.Provider;
+3 -2
View File
@@ -3,11 +3,12 @@ declare module '*.css' {
export default content;
}
interface SvgrComponent extends React.StatelessComponent<React.SVGAttributes<SVGElement>> {}
interface SvgrComponent
extends React.StatelessComponent<React.SVGAttributes<SVGElement>> {}
declare module '*.svg' {
const svgUrl: string;
const svgComponent: SvgrComponent;
export default svgUrl;
export { svgComponent as ReactComponent }
export { svgComponent as ReactComponent };
}
+18 -9
View File
@@ -9,7 +9,11 @@ const d3ZoomInstance = d3Zoom
.scaleExtent([0.5, 2])
.filter(() => !event.button);
export default (zoomPane: MutableRefObject<HTMLDivElement>, onMove: () => void, shiftPressed: boolean): void => {
export default (
zoomPane: MutableRefObject<Element | null>,
onMove: () => void,
shiftPressed: boolean
): void => {
const state = useStoreState(s => ({
transform: s.transform,
d3Selection: s.d3Selection,
@@ -20,8 +24,10 @@ export default (zoomPane: MutableRefObject<HTMLDivElement>, onMove: () => void,
const updateTransform = useStoreActions(actions => actions.updateTransform);
useEffect(() => {
const selection = select(zoomPane.current).call(d3ZoomInstance);
initD3({ zoom: d3ZoomInstance, selection });
if (zoomPane.current) {
const selection = select(zoomPane.current).call(d3ZoomInstance);
initD3({ zoom: d3ZoomInstance, selection });
}
}, []);
useEffect(() => {
@@ -29,8 +35,11 @@ export default (zoomPane: MutableRefObject<HTMLDivElement>, onMove: () => void,
d3ZoomInstance.on('zoom', null);
} else {
d3ZoomInstance.on('zoom', () => {
if (event.sourceEvent && event.sourceEvent.target !== zoomPane.current) {
return false;
if (
event.sourceEvent &&
event.sourceEvent.target !== zoomPane.current
) {
return;
}
updateTransform(event.transform);
@@ -38,11 +47,11 @@ export default (zoomPane: MutableRefObject<HTMLDivElement>, onMove: () => void,
onMove();
});
if (state.d3Selection) {
if (state.d3Selection && state.d3Zoom) {
// we need to restore the graph transform otherwise d3 zoom transform and graph transform are not synced
const graphTransform = d3Zoom.zoomIdentity
.translate(state.transform[0], state.transform[1])
.scale(state.transform[2]);
.translate(state.transform[0], state.transform[1])
.scale(state.transform[2]);
state.d3Selection.call(state.d3Zoom.transform, graphTransform);
}
@@ -52,4 +61,4 @@ export default (zoomPane: MutableRefObject<HTMLDivElement>, onMove: () => void,
d3ZoomInstance.on('zoom', null);
};
}, [shiftPressed]);
}
};
+6 -7
View File
@@ -9,7 +9,7 @@ const useElementUpdater = (elements: Elements): void => {
const state = useStoreState(s => ({
nodes: s.nodes,
edges: s.edges,
transform: s.transform
transform: s.transform,
}));
const setNodes = useStoreActions(a => a.setNodes);
@@ -19,16 +19,17 @@ const useElementUpdater = (elements: Elements): void => {
const nodes = elements.filter(isNode) as Node[];
const edges = elements.filter(isEdge).map(e => parseElement(e)) as Edge[];
const nextNodes = nodes.map((propNode) => {
const nextNodes = nodes.map(propNode => {
const existingNode = state.nodes.find(n => n.id === propNode.id);
if (existingNode) {
const data = !isEqual(existingNode.data, propNode.data) ?
{ ...existingNode.data, ...propNode.data } : existingNode.data;
const data = !isEqual(existingNode.data, propNode.data)
? { ...existingNode.data, ...propNode.data }
: existingNode.data;
return {
...existingNode,
data
data,
};
}
@@ -46,8 +47,6 @@ const useElementUpdater = (elements: Elements): void => {
setEdges(edges);
}
});
return null;
};
export default useElementUpdater;
+11 -7
View File
@@ -8,10 +8,13 @@ import { Elements, Node } from '../types';
interface HookParams {
deleteKeyCode: number;
onElementsRemove: (elements: Elements) => void;
};
}
export default ({ deleteKeyCode, onElementsRemove }: HookParams): void => {
const state = useStoreState(s => ({ selectedElements: s.selectedElements, edges: s.edges }))
const state = useStoreState(s => ({
selectedElements: s.selectedElements,
edges: s.edges,
}));
const setNodesSelection = useStoreActions(a => a.setNodesSelection);
const deleteKeyPressed = useKeyPress(deleteKeyCode);
@@ -20,8 +23,11 @@ export default ({ deleteKeyCode, onElementsRemove }: HookParams): void => {
let elementsToRemove = state.selectedElements;
// we also want to remove the edges if only one node is selected
if (state.selectedElements.length === 1 && !isEdge(state.selectedElements[0])) {
const node = state.selectedElements[0] as unknown as Node;
if (
state.selectedElements.length === 1 &&
!isEdge(state.selectedElements[0])
) {
const node = (state.selectedElements[0] as unknown) as Node;
const connectedEdges = getConnectedEdges([node], state.edges);
elementsToRemove = [...state.selectedElements, ...connectedEdges];
}
@@ -29,7 +35,5 @@ export default ({ deleteKeyCode, onElementsRemove }: HookParams): void => {
onElementsRemove(elementsToRemove);
setNodesSelection({ isActive: false });
}
}, [deleteKeyPressed])
return null;
}, [deleteKeyPressed]);
};
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { isInputDOMNode } from '../utils';
export default (keyCode: number) => {
export default (keyCode: number): boolean => {
const [keyPressed, setKeyPressed] = useState(false);
function downHandler(evt: KeyboardEvent) {
@@ -28,4 +28,4 @@ export default (keyCode: number) => {
}, []);
return keyPressed;
}
};
+1 -1
View File
@@ -10,5 +10,5 @@ export {
isEdge,
removeElements,
addEdge,
getOutgoers
getOutgoers,
} from './utils/graph';
+3 -6
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import classnames from 'classnames';
import { fitView, zoomIn, zoomOut } from '../../utils/graph';
import PlusIcon from '../../../assets/icons/plus.svg';
import PlusIcon from '../../../assets/icons/plus.svg';
import MinusIcon from '../../../assets/icons/minus.svg';
import FitviewIcon from '../../../assets/icons/fitview.svg';
@@ -13,10 +13,7 @@ const baseStyle: CSSProperties = {
left: 10,
};
interface ControlProps {
style?: CSSProperties;
className?: string
};
interface ControlProps extends React.HTMLAttributes<HTMLDivElement> {}
export default ({ style, className }: ControlProps) => {
const mapClasses: string = classnames('react-flow__controls', className);
@@ -26,7 +23,7 @@ export default ({ style, className }: ControlProps) => {
className={mapClasses}
style={{
...baseStyle,
...style
...style,
}}
>
<div
+54 -39
View File
@@ -7,25 +7,26 @@ import { Node } from '../../types';
type StringFunc = (node: Node) => string;
interface MiniMapProps {
style?: CSSProperties;
className?: string | null;
interface MiniMapProps extends React.HTMLAttributes<HTMLCanvasElement> {
bgColor?: string;
nodeColor?: string | StringFunc;
};
nodeColor?: string | StringFunc;
}
const baseStyle: CSSProperties = {
position: 'absolute',
zIndex: 5,
bottom: 10,
right: 10,
width: 200
width: 200,
};
export default (
{ style = {}, className, bgColor = '#f8f8f8', nodeColor = '#ddd' }: MiniMapProps
) => {
const canvasNode = useRef(null);
export default ({
style = {},
className,
bgColor = '#f8f8f8',
nodeColor = '#ddd',
}: MiniMapProps) => {
const canvasNode = useRef<HTMLCanvasElement>(null);
const state = useStoreState(s => ({
width: s.width,
height: s.height,
@@ -34,45 +35,59 @@ export default (
}));
const mapClasses = classnames('react-flow__minimap', className);
const nodePositions = state.nodes.map(n => n.__rg.position);
const width: number = +(style.width || baseStyle.width || 0);
const height = (state.height / (state.width || 1)) * width;
const width: number = +(style.width || baseStyle.width || 0);
const height = (state.height / (state.width || 1)) * width;
const bbox = { x: 0, y: 0, width: state.width, height: state.height };
const scaleFactor = width / state.width;
const nodeColorFunc = (nodeColor instanceof Function ? nodeColor: () => nodeColor) as StringFunc;
const nodeColorFunc = (nodeColor instanceof Function
? nodeColor
: () => nodeColor) as StringFunc;
useEffect(() => {
if (canvasNode && canvasNode.current) {
const ctx = canvasNode.current.getContext('2d');
const nodesInside = getNodesInside(state.nodes, bbox, state.transform, true);
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
nodesInside.forEach((n) => {
const pos = n.__rg.position;
const transformX = state.transform[0];
const transformY = state.transform[1];
const x = (pos.x * state.transform[2]) + transformX;
const y = (pos.y * state.transform[2]) + transformY;
ctx.fillStyle = nodeColorFunc(n);
ctx.fillRect(
(x * scaleFactor),
(y * scaleFactor),
n.__rg.width * scaleFactor * state.transform[2],
n.__rg.height * scaleFactor * state.transform[2]
);
});
if (!canvasNode || !canvasNode.current) {
return;
}
}, [canvasNode.current, nodePositions, state.transform, height])
const ctx = canvasNode.current.getContext('2d');
if (!ctx) {
return;
}
const nodesInside = getNodesInside(
state.nodes,
bbox,
state.transform,
true
);
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
nodesInside.forEach(n => {
const pos = n.__rg.position;
const transformX = state.transform[0];
const transformY = state.transform[1];
const x = pos.x * state.transform[2] + transformX;
const y = pos.y * state.transform[2] + transformY;
ctx.fillStyle = nodeColorFunc(n);
ctx.fillRect(
x * scaleFactor,
y * scaleFactor,
n.__rg.width * scaleFactor * state.transform[2],
n.__rg.height * scaleFactor * state.transform[2]
);
});
}, [canvasNode.current, nodePositions, state.transform, height]);
return (
<canvas
style={{
...baseStyle,
...style,
height
height,
}}
width={width}
height={height}
@@ -80,4 +95,4 @@ export default (
ref={canvasNode}
/>
);
}
};
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as MiniMap } from './MiniMap';
export { default as Controls } from './Controls';
export { default as MiniMap } from './MiniMap';
export { default as Controls } from './Controls';
+1 -1
View File
@@ -1,6 +1,6 @@
import { createTypedHooks } from 'easy-peasy';
import { StoreModel } from './index';
import { StoreModel } from './index';
const typedHooks = createTypedHooks<StoreModel>();
+96 -36
View File
@@ -2,32 +2,44 @@ import { createStore, Action, action } from 'easy-peasy';
import isEqual from 'fast-deep-equal';
import { Selection as D3Selection, ZoomBehavior } from 'd3';
import { getBoundingBox, getNodesInside, getConnectedEdges } from '../utils/graph';
import {
ElementId, Elements, Transform, Node,
Edge, Rect, Dimensions, XYPosition,
OnConnectFunc, SelectionRect, HandleElement
getBoundingBox,
getNodesInside,
getConnectedEdges,
} from '../utils/graph';
import {
ElementId,
Elements,
Transform,
Node,
Edge,
Rect,
Dimensions,
XYPosition,
OnConnectFunc,
SelectionRect,
HandleElement,
} from '../types';
type TransformXYK = {
x: number,
y: number,
k: number
x: number;
y: number;
k: number;
};
type NodePosUpdate = {
id: ElementId,
pos: XYPosition
id: ElementId;
pos: XYPosition;
};
type NodeUpdate = {
id: ElementId,
width: number,
height: number,
id: ElementId;
width: number;
height: number;
handleBounds: {
source: HandleElement,
target: HandleElement
}
source: HandleElement[] | null;
target: HandleElement[] | null;
};
};
type SelectionUpdate = {
@@ -40,6 +52,11 @@ type D3Init = {
selection: D3Selection<Element, unknown, null, undefined>;
};
type SetSnapGrid = {
snapToGrid: boolean;
snapGrid: [number, number];
};
export interface StoreModel {
width: number;
height: number;
@@ -49,17 +66,20 @@ export interface StoreModel {
selectedElements: Elements;
selectedNodesBbox: Rect;
d3Zoom: ZoomBehavior<Element, unknown>;
d3Selection: D3Selection<Element, unknown, null, undefined>;
d3Zoom: ZoomBehavior<Element, unknown> | null;
d3Selection: D3Selection<Element, unknown, null, undefined> | null;
d3Initialised: boolean;
nodesSelectionActive: boolean;
selectionActive: boolean;
selection: SelectionRect | null;
selection: SelectionRect | null;
connectionSourceId: ElementId | null;
connectionSourceId: ElementId | null;
connectionPosition: XYPosition;
snapToGrid: boolean;
snapGrid: [number, number];
onConnect: OnConnectFunc;
setOnConnect: Action<StoreModel, OnConnectFunc>;
@@ -68,7 +88,7 @@ export interface StoreModel {
setEdges: Action<StoreModel, Edge[]>;
updateNodeData: Action<StoreModel, NodeUpdate>;
updateNodeData: Action<StoreModel, NodeUpdate>;
updateNodePos: Action<StoreModel, NodePosUpdate>;
@@ -76,7 +96,7 @@ export interface StoreModel {
setNodesSelection: Action<StoreModel, SelectionUpdate>;
setSelectedElements: Action<StoreModel, Elements | Node | Edge>
setSelectedElements: Action<StoreModel, Elements | Node | Edge>;
updateSelection: Action<StoreModel, SelectionRect>;
@@ -86,10 +106,12 @@ export interface StoreModel {
initD3: Action<StoreModel, D3Init>;
setSnapGrid: Action<StoreModel, SetSnapGrid>;
setConnectionPosition: Action<StoreModel, XYPosition>;
setConnectionSourceId: Action<StoreModel, ElementId>;
};
setConnectionSourceId: Action<StoreModel, ElementId | null>;
}
const storeModel: StoreModel = {
width: 0,
@@ -111,6 +133,9 @@ const storeModel: StoreModel = {
connectionSourceId: null,
connectionPosition: { x: 0, y: 0 },
snapGrid: [16, 16],
snapToGrid: true,
onConnect: () => {},
setOnConnect: action((state, onConnect) => {
@@ -126,22 +151,34 @@ const storeModel: StoreModel = {
}),
updateNodeData: action((state, { id, ...data }) => {
state.nodes.forEach((n) => {
state.nodes.forEach(n => {
if (n.id === id) {
n.__rg = {
...n.__rg,
...data
...data,
};
}
});
}),
updateNodePos: action((state, { id, pos }) => {
state.nodes.forEach((n) => {
let position: XYPosition = pos;
if (state.snapToGrid) {
const transformedGridSizeX = state.snapGrid[0] * state.transform[2];
const transformedGridSizeY = state.snapGrid[1] * state.transform[2];
position = {
x: transformedGridSizeX * Math.round(pos.x / transformedGridSizeX),
y: transformedGridSizeY * Math.round(pos.y / transformedGridSizeY),
};
}
state.nodes.forEach(n => {
if (n.id === id) {
n.__rg = {
...n.__rg,
position: pos
position,
};
}
});
@@ -152,13 +189,17 @@ const storeModel: StoreModel = {
}),
setNodesSelection: action((state, { isActive, selection }) => {
if (!isActive) {
if (!isActive || typeof selection === 'undefined') {
state.nodesSelectionActive = false;
state.selectedElements = [];
return;
}
const selectedNodes = getNodesInside(state.nodes, selection, state.transform);
const selectedNodes = getNodesInside(
state.nodes,
selection,
state.transform
);
const selectedNodesBbox = getBoundingBox(selectedNodes);
state.selection = selection;
@@ -169,21 +210,35 @@ const storeModel: StoreModel = {
setSelectedElements: action((state, elements) => {
const selectedElementsArr = Array.isArray(elements) ? elements : [elements];
const selectedElementsUpdated = !isEqual(selectedElementsArr, state.selectedElements);
const selectedElements = selectedElementsUpdated ? selectedElementsArr : state.selectedElements;
const selectedElementsUpdated = !isEqual(
selectedElementsArr,
state.selectedElements
);
const selectedElements = selectedElementsUpdated
? selectedElementsArr
: state.selectedElements;
state.selectedElements = selectedElements;
}),
updateSelection: action((state, selection) => {
const selectedNodes = getNodesInside(state.nodes, selection, state.transform);
const selectedNodes = getNodesInside(
state.nodes,
selection,
state.transform
);
const selectedEdges = getConnectedEdges(selectedNodes, state.edges);
const nextSelectedElements = [...selectedNodes, ...selectedEdges];
const selectedElementsUpdated = !isEqual(nextSelectedElements, state.selectedElements);
const nextSelectedElements = [...selectedNodes, ...selectedEdges];
const selectedElementsUpdated = !isEqual(
nextSelectedElements,
state.selectedElements
);
state.selection = selection;
state.selectedElements = selectedElementsUpdated ? nextSelectedElements: state.selectedElements
state.selectedElements = selectedElementsUpdated
? nextSelectedElements
: state.selectedElements;
}),
updateTransform: action((state, transform) => {
@@ -207,7 +262,12 @@ const storeModel: StoreModel = {
setConnectionSourceId: action((state, sourceId) => {
state.connectionSourceId = sourceId;
})
}),
setSnapGrid: action((state, { snapToGrid, snapGrid }) => {
state.snapToGrid = snapToGrid;
state.snapGrid = snapGrid;
}),
};
const store = createStore(storeModel);
+61 -66
View File
@@ -2,136 +2,131 @@ import { CSSProperties, SVGAttributes } from 'react';
export type ElementId = string;
export type Elements = Array<Node | Edge>;
export type Elements = Array<Node | Edge>;
export type Transform = [number, number, number];
export type Position = 'left' | 'top' | 'right' | 'bottom';
export type Position = 'left' | 'top' | 'right' | 'bottom';
export type XYPosition = {
x: number,
y: number
x: number;
y: number;
};
export enum GridType {
Lines = 'lines',
Dots = 'dots',
};
}
export type HandleType = 'source' | 'target';
export type HandleType = 'source' | 'target';
export type NodeTypesType = { [key: string]: React.ReactNode };
export type NodeTypesType = { [key: string]: React.ReactNode };
export type EdgeTypesType = NodeTypesType;
export interface Dimensions {
width: number,
height: number
width: number;
height: number;
}
export interface Rect extends Dimensions {
x: number,
y: number
};
x: number;
y: number;
}
export interface SelectionRect extends Rect {
startX: number;
startY: number;
draw: boolean
};
draw: boolean;
}
export interface Node {
id: ElementId,
position?: XYPosition,
type?: string,
__rg?: any,
data?: any,
style?: CSSProperties
};
id: ElementId;
position: XYPosition;
type?: string;
__rg?: any;
data?: any;
style?: CSSProperties;
}
export interface Edge {
id: ElementId,
type?: string,
source: ElementId,
target: ElementId,
style?: SVGAttributes<{}>
animated?: boolean
};
id: ElementId;
type?: string;
source: ElementId;
target: ElementId;
style?: SVGAttributes<{}>;
animated?: boolean;
}
export interface EdgeProps {
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
style?: SVGAttributes<{}>
};
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
style?: SVGAttributes<{}>;
}
export interface EdgeBezierProps extends EdgeProps{
sourcePosition: Position,
targetPosition: Position
};
export interface EdgeBezierProps extends EdgeProps {
sourcePosition: Position;
targetPosition: Position;
}
export interface NodeProps {
id: ElementId,
type: string,
id: ElementId;
type: string;
data: any;
selected: boolean;
style?: CSSProperties;
};
}
export interface NodeComponentProps {
id: ElementId,
id: ElementId;
type: string;
data: any;
selected?: boolean;
transform?: Transform;
xPos?: number;
yPos?: number;
onClick?: () => any;
onClick?: (node: Node) => void | undefined;
onNodeDragStop?: () => any;
style?: CSSProperties;
};
}
export type FitViewParams = {
padding: number
padding: number;
};
export type FitViewFunc = (fitViewOptions: FitViewParams) => void;
type OnLoadParams = {
zoomIn: () => void;
zoomOut: () => void;
fitView: FitViewFunc
fitView: FitViewFunc;
};
export type OnLoadFunc = (params: OnLoadParams) => void;
export type OnConnectParams = {
source: ElementId;
target: ElementId;
};
export type OnConnectFunc = (params: OnConnectParams) => void;
export type Connection = {
source: ElementId;
target: ElementId;
source: ElementId | null;
target: ElementId | null;
};
export type OnConnectFunc = (params: Connection) => void;
export interface HandleElement {
id?: ElementId;
id?: ElementId | null;
position: Position;
x: number;
y: number;
width: number;
height: number;
};
}
export interface EdgeWrapperProps {
id: ElementId,
source: ElementId,
target: ElementId,
type: any,
onClick?: (edge: Edge) => void
animated?: boolean,
selected?: boolean,
};
export interface EdgeCompProps {
id: ElementId;
source: ElementId;
target: ElementId;
type: any;
onClick?: (edge: Edge) => void;
animated?: boolean;
selected?: boolean;
}
+127 -70
View File
@@ -1,9 +1,18 @@
import { zoomIdentity } from 'd3-zoom';
import store from '../store';
import { ElementId, Node, Edge, Elements, Transform, XYPosition, Rect, FitViewParams } from '../types';
import {
ElementId,
Node,
Edge,
Elements,
Transform,
XYPosition,
Rect,
FitViewParams,
} from '../types';
export const isEdge = (element: Node | Edge): boolean =>
export const isEdge = (element: Node | Edge): boolean =>
element.hasOwnProperty('source') && element.hasOwnProperty('target');
export const isNode = (element: Node | Edge): boolean =>
@@ -14,14 +23,19 @@ export const getOutgoers = (node: Node, elements: Elements): Elements => {
return [];
}
const outgoerIds = elements.filter((e: Edge) => e.source === node.id).map((e: Edge) => e.target);
const outgoerIds = (elements as Edge[])
.filter(e => e.source === node.id)
.map(e => e.target);
return elements.filter(e => outgoerIds.includes(e.id));
};
export const removeElements = (elementsToRemove: Elements, elements: Elements): Elements => {
export const removeElements = (
elementsToRemove: Elements,
elements: Elements
): Elements => {
const nodeIdsToRemove = elementsToRemove.map(n => n.id);
return elements.filter((element) => {
return elements.filter(element => {
const edgeElement = element as Edge;
return !(
nodeIdsToRemove.includes(element.id) ||
@@ -36,36 +50,45 @@ function getEdgeId(edgeParams: Edge): ElementId {
}
export const addEdge = (edgeParams: Edge, elements: Elements): Elements => {
if (!edgeParams.source || !edgeParams.target) {
if (!edgeParams.source || !edgeParams.target) {
throw new Error('Can not create edge. An edge needs a source and a target');
}
return elements.concat({
...edgeParams,
id: typeof edgeParams.id !== 'undefined' ? edgeParams.id : getEdgeId(edgeParams)
id:
typeof edgeParams.id !== 'undefined'
? edgeParams.id
: getEdgeId(edgeParams),
});
}
};
const pointToRendererPoint = ({ x, y }: XYPosition, transform: Transform): XYPosition => {
const pointToRendererPoint = (
{ x, y }: XYPosition,
transform: Transform
): XYPosition => {
const rendererX = (x - transform[0]) * (1 / transform[2]);
const rendererY = (y - transform[1]) * (1 / transform[2]);
return {
x: rendererX,
y: rendererY
y: rendererY,
};
};
export const parseElement = (element: Node | Edge, transform?: Transform): Node | Edge => {
export const parseElement = (
element: Node | Edge,
transform: Transform = [0, 0, 1]
): Node | Edge => {
if (!element.id) {
throw new Error('All elements (nodes and edges) need to have an id.',)
throw new Error('All elements (nodes and edges) need to have an id.');
}
if (isEdge(element)) {
return {
...element,
id: element.id.toString(),
type: element.type || 'default'
type: element.type || 'default',
};
}
@@ -79,76 +102,88 @@ export const parseElement = (element: Node | Edge, transform?: Transform): Node
position: pointToRendererPoint(nodeElement.position, transform),
width: null,
height: null,
handleBounds : {}
}
handleBounds: {},
},
};
};
export const getBoundingBox = (nodes: Node[]): Rect => {
const bbox = nodes.reduce((res, node) => {
const { position } = node.__rg;
const x2 = position.x + node.__rg.width;
const y2 = position.y + node.__rg.height;
const bbox = nodes.reduce(
(res, node) => {
const { position } = node.__rg;
const x2 = position.x + node.__rg.width;
const y2 = position.y + node.__rg.height;
if (position.x < res.minX) {
res.minX = position.x;
if (position.x < res.minX) {
res.minX = position.x;
}
if (x2 > res.maxX) {
res.maxX = x2;
}
if (position.y < res.minY) {
res.minY = position.y;
}
if (y2 > res.maxY) {
res.maxY = y2;
}
return res;
},
{
minX: Number.MAX_VALUE,
minY: Number.MAX_VALUE,
maxX: 0,
maxY: 0,
}
if (x2 > res.maxX) {
res.maxX = x2;
}
if (position.y < res.minY) {
res.minY = position.y;
}
if (y2 > res.maxY) {
res.maxY = y2;
}
return res;
}, {
minX: Number.MAX_VALUE,
minY: Number.MAX_VALUE,
maxX: 0,
maxY: 0
});
);
return {
x: bbox.minX,
y: bbox.minY,
width: bbox.maxX - bbox.minX,
height: bbox.maxY - bbox.minY
height: bbox.maxY - bbox.minY,
};
};
export const graphPosToZoomedPos = (pos: XYPosition, transform: Transform): XYPosition => {
export const graphPosToZoomedPos = (
pos: XYPosition,
transform: Transform
): XYPosition => {
return {
x: (pos.x * transform[2]) + transform[0],
y: (pos.y * transform[2]) + transform[1]
x: pos.x * transform[2] + transform[0],
y: pos.y * transform[2] + transform[1],
};
}
};
export const getNodesInside = (nodes: Node[], bbox: Rect, transform: Transform = [0, 0, 1], partially: boolean = false): Node[] => {
return nodes
.filter(n => {
const bboxPos = {
x: (bbox.x - transform[0]) * (1 / transform[2]),
y: (bbox.y - transform[1]) * (1 / transform[2])
};
const bboxWidth = bbox.width * (1 / transform[2]);
const bboxHeight = bbox.height * (1 / transform[2]);
const { position, width, height } = n.__rg;
const nodeWidth = partially ? -width : width;
const nodeHeight = partially ? 0 : height;
const offsetX = partially ? width : 0;
const offsetY = partially ? height : 0;
export const getNodesInside = (
nodes: Node[],
bbox: Rect,
transform: Transform = [0, 0, 1],
partially: boolean = false
): Node[] => {
return nodes.filter(n => {
const bboxPos = {
x: (bbox.x - transform[0]) * (1 / transform[2]),
y: (bbox.y - transform[1]) * (1 / transform[2]),
};
const bboxWidth = bbox.width * (1 / transform[2]);
const bboxHeight = bbox.height * (1 / transform[2]);
const { position, width, height } = n.__rg;
const nodeWidth = partially ? -width : width;
const nodeHeight = partially ? 0 : height;
const offsetX = partially ? width : 0;
const offsetY = partially ? height : 0;
return (
(position.x + offsetX > bboxPos.x && (position.x + nodeWidth) < (bboxPos.x + bboxWidth)) &&
(position.y + offsetY > bboxPos.y && (position.y + nodeHeight) < (bboxPos.y + bboxHeight))
);
});
return (
position.x + offsetX > bboxPos.x &&
position.x + nodeWidth < bboxPos.x + bboxWidth &&
(position.y + offsetY > bboxPos.y &&
position.y + nodeHeight < bboxPos.y + bboxHeight)
);
});
};
export const getConnectedEdges = (nodes: Node[], edges: Edge[]): Edge[] => {
@@ -167,23 +202,45 @@ export const getConnectedEdges = (nodes: Node[], edges: Edge[]): Edge[] => {
export const fitView = ({ padding }: FitViewParams = { padding: 0 }): void => {
const state = store.getState();
if (!state.d3Selection || !state.d3Zoom) {
return;
}
const bounds = getBoundingBox(state.nodes);
const maxBoundsSize = Math.max(bounds.width, bounds.height);
const k = Math.min(state.width, state.height) / (maxBoundsSize + (maxBoundsSize * padding));
const boundsCenterX = bounds.x + (bounds.width / 2);
const boundsCenterY = bounds.y + (bounds.height / 2);
const transform = [(state.width / 2) - (boundsCenterX * k), (state.height / 2) - (boundsCenterY * k)];
const fittedTransform = zoomIdentity.translate(transform[0], transform[1]).scale(k);
const k =
Math.min(state.width, state.height) /
(maxBoundsSize + maxBoundsSize * padding);
const boundsCenterX = bounds.x + bounds.width / 2;
const boundsCenterY = bounds.y + bounds.height / 2;
const transform = [
state.width / 2 - boundsCenterX * k,
state.height / 2 - boundsCenterY * k,
];
const fittedTransform = zoomIdentity
.translate(transform[0], transform[1])
.scale(k);
state.d3Selection.call(state.d3Zoom.transform, fittedTransform);
};
export const zoomIn = (): void => {
const state = store.getState();
if (!state.d3Zoom || !state.d3Selection) {
return;
}
state.d3Zoom.scaleTo(state.d3Selection, state.transform[2] + 0.2);
};
export const zoomOut = (): void => {
const state = store.getState();
if (!state.d3Zoom || !state.d3Selection) {
return;
}
state.d3Zoom.scaleTo(state.d3Selection, state.transform[2] - 0.2);
};
+7 -3
View File
@@ -1,12 +1,16 @@
import { DraggableEvent } from 'react-draggable';
import { MouseEvent as ReactMouseEvent } from 'react';
export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEvent) => {
export const isInputDOMNode = (
e: ReactMouseEvent | DraggableEvent | KeyboardEvent
) => {
const target = e.target as HTMLElement;
return e && target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.nodeName);
return (
e && target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.nodeName)
);
};
export const getDimensions = (node: HTMLDivElement) => ({
width: node.offsetWidth,
height: node.offsetHeight
height: node.offsetHeight,
});