diff --git a/example/src/Basic/index.js b/example/src/Basic/index.js index 3395d29b..6c5acbe4 100644 --- a/example/src/Basic/index.js +++ b/example/src/Basic/index.js @@ -6,10 +6,10 @@ const onNodeDragStop = (event, node) => console.log('drag stop', node); const onElementClick = (event, element) => console.log('click', element); const initialElements = [ - { id: '1', type: 'input', data: { label: 'Node 1' }, position: { x: 250, y: 5 } }, - { id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 } }, - { id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 } }, - { id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 } }, + { id: '1', type: 'input', data: { label: 'Node 1' }, position: { x: 250, y: 5 }, className: 'light' }, + { id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 }, className: 'light' }, + { id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 }, className: 'light' }, + { id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 }, className: 'light' }, { id: 'e1-2', source: '1', target: '2', animated: true }, { id: 'e1-3', source: '1', target: '3' }, ]; @@ -40,9 +40,23 @@ const BasicFlow = () => { }; const logToObject = () => console.log(rfInstance.toObject()); - const resetTransform = () => rfInstance.setTransform({ x: 0, y: 0, zoom: 1 }); + const toggleClassnames = () => { + setElements((elms) => { + return elms.map((el) => { + if (isNode(el)) { + return { + ...el, + className: el.className === 'light' ? 'dark' : 'light', + }; + } + + return el; + }); + }); + }; + return ( { + diff --git a/example/src/index.css b/example/src/index.css index 76f50147..10b22e56 100644 --- a/example/src/index.css +++ b/example/src/index.css @@ -140,6 +140,11 @@ nav a.active:before { color: #f8f8f8; } +.react-flow__node.dark { + background: #557; + color: #f8f8f8; +} + .react-flow__node-selectorNode { font-size: 12px; background: #f0f2f3; diff --git a/src/components/ElementUpdater/index.tsx b/src/components/ElementUpdater/index.tsx index a84c5c01..5bbe70a6 100644 --- a/src/components/ElementUpdater/index.tsx +++ b/src/components/ElementUpdater/index.tsx @@ -1,147 +1,18 @@ import { useEffect } from 'react'; -import isEqual from 'fast-deep-equal'; -import { useStoreState, useStoreActions } from '../../store/hooks'; -import { parseElement, isNode, isEdge } from '../../utils/graph'; -import { Elements, Node, Edge, FlowElement } from '../../types'; +import { useStoreActions } from '../../store/hooks'; +import { Elements } from '../../types'; interface ElementUpdaterProps { elements: Elements; } const ElementUpdater = ({ elements }: ElementUpdaterProps) => { - const stateElements = useStoreState((state) => state.elements); const setElements = useStoreActions((actions) => actions.setElements); useEffect(() => { - const nextElements: Elements = elements.map((propElement) => { - const existingElement = stateElements.find((el) => el.id === propElement.id?.toString()); - - if (existingElement) { - const data = !isEqual(existingElement.data, propElement.data) - ? { ...existingElement.data, ...propElement.data } - : existingElement.data; - - const style = !isEqual(existingElement.style, propElement.style) - ? { ...existingElement.style, ...propElement.style } - : existingElement.style; - - const elementProps = { - ...existingElement, - }; - - if (typeof data !== 'undefined') { - elementProps.data = data; - } - - if (typeof style !== 'undefined') { - elementProps.style = style; - } - - if (typeof propElement.className !== 'undefined') { - elementProps.className = propElement.className; - } - - if (typeof propElement.isHidden !== 'undefined') { - elementProps.isHidden = propElement.isHidden; - } - - if (typeof propElement.type !== 'undefined') { - elementProps.type = propElement.type; - - // we reset the elements dimensions here in order to force a re-calculation of the bounds. - // When the type of a node changes it is possible that the number or positions of handles changes too. - if (isNode(existingElement) && propElement.type !== existingElement.type) { - existingElement.__rf.width = null; - existingElement.__rf.height = null; - } - } - - if (isNode(existingElement)) { - const propNode = propElement as Node; - const nodeProps = elementProps as Node; - - const positionChanged = - existingElement.position.x !== propNode.position.x || existingElement.position.y !== propNode.position.y; - - if (positionChanged) { - nodeProps.__rf = { - ...existingElement.__rf, - position: propNode.position, - }; - nodeProps.position = propNode.position; - } - - if (typeof propNode.draggable !== 'undefined') { - nodeProps.draggable = propNode.draggable; - } - - if (typeof propNode.selectable !== 'undefined') { - nodeProps.selectable = propNode.selectable; - } - - if (typeof propNode.connectable !== 'undefined') { - nodeProps.connectable = propNode.connectable; - } - - return nodeProps; - } else if (isEdge(existingElement)) { - const propEdge = propElement as Edge; - const edgeProps = elementProps as Edge; - - const labelStyle = !isEqual(existingElement.labelStyle, propEdge.labelStyle) - ? { ...existingElement.labelStyle, ...propEdge.labelStyle } - : existingElement.labelStyle; - - const labelBgStyle = !isEqual(existingElement.labelBgStyle, propEdge.labelBgStyle) - ? { ...existingElement.labelBgStyle, ...propEdge.labelBgStyle } - : existingElement.labelBgStyle; - - if (typeof propEdge.label !== 'undefined') { - edgeProps.label = propEdge.label; - } - - if (typeof labelStyle !== 'undefined') { - edgeProps.labelStyle = labelStyle; - } - - if (typeof propEdge.labelShowBg !== 'undefined') { - edgeProps.labelShowBg = propEdge.labelShowBg; - } - - if (typeof propEdge.labelBgPadding !== 'undefined') { - edgeProps.labelBgPadding = propEdge.labelBgPadding; - } - - if (typeof propEdge.labelBgBorderRadius !== 'undefined') { - edgeProps.labelBgBorderRadius = propEdge.labelBgBorderRadius; - } - - if (typeof labelBgStyle !== 'undefined') { - edgeProps.labelBgStyle = labelBgStyle; - } - - if (typeof propEdge.animated !== 'undefined') { - edgeProps.animated = propEdge.animated; - } - - if (typeof propEdge.arrowHeadType !== 'undefined') { - edgeProps.arrowHeadType = propEdge.arrowHeadType; - } - - return edgeProps; - } - } - - return parseElement(propElement) as FlowElement; - }); - - const elementsChanged: boolean = !isEqual(stateElements, nextElements); - - if (elementsChanged) { - setElements(nextElements); - } - }, [elements, stateElements]); + setElements(elements); + }, [elements]); return null; }; diff --git a/src/components/Nodes/utils.ts b/src/components/Nodes/utils.ts index 7d2a7d31..e917e853 100644 --- a/src/components/Nodes/utils.ts +++ b/src/components/Nodes/utils.ts @@ -1,7 +1,16 @@ import { HandleElement, Position } from '../../types'; import { getDimensions } from '../../utils'; -export const getHandleBounds = ( +export const getHandleBounds = (nodeElement: HTMLDivElement, scale: number) => { + const bounds = nodeElement.getBoundingClientRect(); + + return { + source: getHandleBoundsByHandleType('.source', nodeElement, bounds, scale), + target: getHandleBoundsByHandleType('.target', nodeElement, bounds, scale), + }; +}; + +export const getHandleBoundsByHandleType = ( selector: string, nodeElement: HTMLDivElement, parentBounds: ClientRect | DOMRect, diff --git a/src/components/Nodes/wrapNode.tsx b/src/components/Nodes/wrapNode.tsx index f498326a..3a3b95f4 100644 --- a/src/components/Nodes/wrapNode.tsx +++ b/src/components/Nodes/wrapNode.tsx @@ -35,6 +35,7 @@ export default (NodeComponent: ComponentType) => { snapToGrid, snapGrid, isDragging, + resizeObserver, }: WrapNodeProps) => { const updateNodeDimensions = useStoreActions((actions) => actions.updateNodeDimensions); const addSelectedElements = useStoreActions((actions) => actions.addSelectedElements); @@ -160,24 +161,19 @@ export default (NodeComponent: ComponentType) => { useEffect(() => { if (nodeElement.current && !isHidden) { updateNodeDimensions({ id, nodeElement: nodeElement.current }); + } + }, [id, isHidden]); - const resizeObserver = new ResizeObserver(() => { - if (nodeElement.current) { - updateNodeDimensions({ id, nodeElement: nodeElement.current }); - } - }); + useEffect(() => { + if (nodeElement.current) { + const currNode = nodeElement.current; + resizeObserver.observe(currNode); - resizeObserver.observe(nodeElement.current); - - return () => { - if (resizeObserver && nodeElement.current) { - resizeObserver.unobserve(nodeElement.current); - } - }; + return () => resizeObserver.unobserve(currNode); } return; - }, [id, isHidden]); + }, []); if (isHidden) { return null; @@ -213,6 +209,7 @@ export default (NodeComponent: ComponentType) => { onMouseLeave={onMouseLeaveHandler} onContextMenu={onContextMenuHandler} onClick={onSelectNodeHandler} + data-id={id} > { const elementsSelectable = useStoreState((state) => state.elementsSelectable); const viewportBox = useStoreState((state) => state.viewportBox); const nodes = useStoreState((state) => state.nodes); + const batchUpdateNodeDimensions = useStoreActions((actions) => actions.batchUpdateNodeDimensions); const visibleNodes = props.onlyRenderVisibleElements ? getNodesInside(nodes, viewportBox, transform, true) : nodes; @@ -37,6 +38,19 @@ const NodeRenderer = (props: NodeRendererProps) => { [transform[0], transform[1], transform[2]] ); + const resizeObserver = useMemo( + () => + new ResizeObserver((entries) => { + const updates = entries.map((entry) => ({ + id: entry.target.getAttribute('data-id') as string, + nodeElement: entry.target as HTMLDivElement, + })); + + batchUpdateNodeDimensions({ updates }); + }), + [] + ); + return (
{visibleNodes.map((node) => { @@ -81,6 +95,7 @@ const NodeRenderer = (props: NodeRendererProps) => { isDraggable={isDraggable} isSelectable={isSelectable} isConnectable={isConnectable} + resizeObserver={resizeObserver} /> ); })} diff --git a/src/store/index.ts b/src/store/index.ts index 5c33c697..3c3948cf 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,9 +1,9 @@ -import { createStore, Action, action, ActionOn, actionOn, Thunk, thunk, computed, Computed } from 'easy-peasy'; +import { createStore, Action, action, Thunk, thunk, computed, Computed } from 'easy-peasy'; import isEqual from 'fast-deep-equal'; import { Selection as D3Selection, ZoomBehavior } from 'd3'; import { getDimensions } from '../utils'; -import { getNodesInside, getConnectedEdges, getRectOfNodes, isNode, isEdge } from '../utils/graph'; +import { getNodesInside, getConnectedEdges, getRectOfNodes, isNode, isEdge, parseElement } from '../utils/graph'; import { getHandleBounds } from '../components/Nodes/utils'; import { @@ -33,6 +33,10 @@ type NodeDimensionUpdate = { nodeElement: HTMLDivElement; }; +type NodeDimensionUpdates = { + updates: NodeDimensionUpdate[]; +}; + type InitD3Zoom = { d3Zoom: ZoomBehavior; d3Selection: D3Selection; @@ -90,8 +94,8 @@ export interface StoreModel { setElements: Action; + batchUpdateNodeDimensions: Action; updateNodeDimensions: Action; - updateNodeHandlePositions: ActionOn; updateNodePos: Action; updateNodePosDiff: Action; @@ -196,54 +200,91 @@ export const storeModel: StoreModel = { state.onConnectEnd = onConnectEnd; }), - setElements: action((state, elements) => { - state.elements = elements; - }), + setElements: action((state, propElements) => { + // remove deleted elements + for (let i = 0; i < state.elements.length; i++) { + const se = state.elements[i]; + const elementExistsInProps = propElements.find((pe) => pe.id === se.id); - updateNodeDimensions: action((state, { id, nodeElement }) => { - const dimensions = getDimensions(nodeElement); - const matchingNode = state.nodes.find((n) => n.id === id); - - // only update when size change - if ( - !matchingNode || - (matchingNode.__rf.width === dimensions.width && matchingNode.__rf.height === dimensions.height) - ) { - return; + if (!elementExistsInProps) { + state.elements.splice(i, 1); + i--; + } } - state.elements.forEach((n) => { - if (n.id === id && isNode(n)) { - n.__rf.width = dimensions.width; - n.__rf.height = dimensions.height; + propElements.forEach((el) => { + const storeElementIndex = state.elements.findIndex((se) => se.id === el.id); + + // update existing element + if (storeElementIndex !== -1) { + const storeElement = state.elements[storeElementIndex]; + + if (isNode(storeElement)) { + const propNode = el as Node; + const positionChanged = + storeElement.position.x !== propNode.position.x || storeElement.position.y !== propNode.position.y; + const typeChanged = typeof propNode.type !== 'undefined' && propNode.type !== storeElement.type; + + state.elements[storeElementIndex] = { + ...storeElement, + ...propNode, + }; + + if (positionChanged) { + (state.elements[storeElementIndex] as Node).__rf.position = propNode.position; + } + + if (typeChanged) { + // we reset the elements dimensions here in order to force a re-calculation of the bounds. + // When the type of a node changes it is possible that the number or positions of handles changes too. + (state.elements[storeElementIndex] as Node).__rf.width = null; + } + } else { + state.elements[storeElementIndex] = { + ...storeElement, + ...el, + }; + } + } else { + // add new element + state.elements.push(parseElement(el)); } }); }), - updateNodeHandlePositions: actionOn( - (actions) => actions.updateNodeDimensions, - (state, target) => { - const { nodeElement, id } = target.payload; - const matchingNode = state.nodes.find((n) => n.id === id); + batchUpdateNodeDimensions: action((state, { updates }) => { + updates.forEach((update) => { + const dimensions = getDimensions(update.nodeElement); + const matchingIndex = state.elements.findIndex((n) => n.id === update.id); + const matchingNode = state.elements[matchingIndex] as Node; - if (!matchingNode) { - return; + if ( + matchingIndex !== -1 && + dimensions.width && + dimensions.height && + (matchingNode.__rf.width !== dimensions.width || matchingNode.__rf.height !== dimensions.height) + ) { + const handleBounds = getHandleBounds(update.nodeElement, state.transform[2]); + + (state.elements[matchingIndex] as Node).__rf.width = dimensions.width; + (state.elements[matchingIndex] as Node).__rf.height = dimensions.height; + (state.elements[matchingIndex] as Node).__rf.handleBounds = handleBounds; } + }); + }), - const bounds = nodeElement.getBoundingClientRect(); + updateNodeDimensions: action((state, { id, nodeElement }) => { + const dimensions = getDimensions(nodeElement); + const matchingIndex = state.elements.findIndex((n) => n.id === id); - const handleBounds = { - source: getHandleBounds('.source', nodeElement, bounds, state.transform[2]), - target: getHandleBounds('.target', nodeElement, bounds, state.transform[2]), - }; + if (matchingIndex !== -1 && dimensions.width && dimensions.height) { + const handleBounds = getHandleBounds(nodeElement, state.transform[2]); - state.elements.forEach((n) => { - if (n.id === id && isNode(n)) { - n.__rf.handleBounds = handleBounds; - } - }); + (state.elements[matchingIndex] as Node).__rf.width = dimensions.width; + (state.elements[matchingIndex] as Node).__rf.height = dimensions.height; + (state.elements[matchingIndex] as Node).__rf.handleBounds = handleBounds; } - ), + }), updateNodePos: action((state, { id, pos }) => { let position: XYPosition = pos; diff --git a/src/types/index.ts b/src/types/index.ts index eb94c85d..3476d7a8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -224,6 +224,7 @@ export interface WrapNodeProps { snapToGrid?: boolean; snapGrid?: SnapGrid; isDragging?: boolean; + resizeObserver: ResizeObserver; } export type FitViewParams = { diff --git a/src/utils/index.ts b/src/utils/index.ts index caffa530..13832a31 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,8 @@ import { DraggableEvent } from 'react-draggable'; import { MouseEvent as ReactMouseEvent } from 'react'; +import { Dimensions } from '../types'; + export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEvent) => { const target = e?.target as HTMLElement; @@ -9,7 +11,7 @@ export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEve ); }; -export const getDimensions = (node: HTMLDivElement) => ({ +export const getDimensions = (node: HTMLDivElement): Dimensions => ({ width: node.offsetWidth, height: node.offsetHeight, });