diff --git a/example/src/Layouting/index.js b/example/src/Layouting/index.js index 97028dde..90954d14 100644 --- a/example/src/Layouting/index.js +++ b/example/src/Layouting/index.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import ReactFlow, { ReactFlowProvider, addEdge, removeElements, Controls, isNode } from 'react-flow-renderer'; import dagre from 'dagre'; @@ -9,6 +9,11 @@ import './layouting.css'; const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); +const nodeExtent = [ + [0, 0], + [1000, 1000], +]; + const LayoutFlow = () => { const [elements, setElements] = useState(initialElements); const onConnect = (params) => setElements((els) => addEdge(params, els)); @@ -44,14 +49,16 @@ const LayoutFlow = () => { setElements(layoutedElements); }; - useEffect(() => { - onLayout('TB'); - }, [onLayout]); - return (
- + onLayout('TB')} + >
diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index 5defbb05..363b50b9 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -70,6 +70,7 @@ const GraphView = ({ defaultZoom, defaultPosition, translateExtent, + nodeExtent, arrowHeadColor, markerEndId, zoomOnScroll, @@ -97,6 +98,7 @@ const GraphView = ({ const setMinZoom = useStoreActions((actions) => actions.setMinZoom); const setMaxZoom = useStoreActions((actions) => actions.setMaxZoom); const setTranslateExtent = useStoreActions((actions) => actions.setTranslateExtent); + const setNodeExtent = useStoreActions((actions) => actions.setNodeExtent); const setConnectionMode = useStoreActions((actions) => actions.setConnectionMode); const currentStore = useStore(); const { zoomIn, zoomOut, zoomTo, transform, fitView, initialized } = useZoomPanHelper(); @@ -192,6 +194,12 @@ const GraphView = ({ } }, [translateExtent]); + useEffect(() => { + if (typeof nodeExtent !== 'undefined') { + setNodeExtent(nodeExtent); + } + }, [nodeExtent]); + useEffect(() => { if (typeof connectionMode !== 'undefined') { setConnectionMode(connectionMode); diff --git a/src/container/ReactFlow/index.tsx b/src/container/ReactFlow/index.tsx index fbc0cc0d..6c974de4 100644 --- a/src/container/ReactFlow/index.tsx +++ b/src/container/ReactFlow/index.tsx @@ -30,6 +30,7 @@ import { KeyCode, PanOnScrollMode, OnEdgeUpdateFunc, + NodeExtent, } from '../../types'; import '../../style.css'; @@ -97,6 +98,7 @@ export interface ReactFlowProps extends Omit, 'on defaultZoom?: number; defaultPosition?: [number, number]; translateExtent?: TranslateExtent; + nodeExtent?: NodeExtent; arrowHeadColor?: string; markerEndId?: string; zoomOnScroll?: boolean; @@ -154,6 +156,7 @@ const ReactFlow = ({ defaultZoom = 1, defaultPosition = [0, 0], translateExtent, + nodeExtent, arrowHeadColor = '#b1b1b7', markerEndId, zoomOnScroll = true, @@ -216,6 +219,7 @@ const ReactFlow = ({ defaultZoom={defaultZoom} defaultPosition={defaultPosition} translateExtent={translateExtent} + nodeExtent={nodeExtent} arrowHeadColor={arrowHeadColor} markerEndId={markerEndId} zoomOnScroll={zoomOnScroll} diff --git a/src/store/index.ts b/src/store/index.ts index e9f3973a..7a094dc9 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,7 +2,7 @@ import { createStore, Action, action, Thunk, thunk, computed, Computed } from 'e import isEqual from 'fast-deep-equal'; import { Selection as D3Selection, ZoomBehavior } from 'd3'; -import { getDimensions } from '../utils'; +import { clampPosition, getDimensions } from '../utils'; import { getNodesInside, getConnectedEdges, getRectOfNodes, isNode, isEdge, parseElement } from '../utils/graph'; import { getHandleBounds } from '../components/Nodes/utils'; @@ -27,6 +27,7 @@ import { TranslateExtent, SnapGrid, ConnectionMode, + NodeExtent, } from '../types'; type NodeDimensionUpdate = { @@ -44,6 +45,7 @@ type InitD3Zoom = { d3ZoomHandler: ((this: Element, event: any, d: unknown) => void) | undefined; transform: Transform; }; + export interface StoreModel { width: number; height: number; @@ -61,6 +63,7 @@ export interface StoreModel { minZoom: number; maxZoom: number; translateExtent: TranslateExtent; + nodeExtent: NodeExtent; nodesSelectionActive: boolean; selectionActive: boolean; @@ -120,6 +123,7 @@ export interface StoreModel { setMaxZoom: Action; setTranslateExtent: Action; + setNodeExtent: Action; setSnapToGrid: Action; setSnapGrid: Action; @@ -162,6 +166,10 @@ export const storeModel: StoreModel = { [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], ], + nodeExtent: [ + [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], + [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], + ], nodesSelectionActive: false, selectionActive: false, @@ -236,7 +244,10 @@ export const storeModel: StoreModel = { }; if (positionChanged) { - (state.elements[storeElementIndex] as Node).__rf.position = propNode.position; + (state.elements[storeElementIndex] as Node).__rf.position = clampPosition( + propNode.position, + state.nodeExtent + ); } if (typeChanged) { @@ -252,7 +263,7 @@ export const storeModel: StoreModel = { } } else { // add new element - state.elements.push(parseElement(el)); + state.elements.push(parseElement(el, state.nodeExtent)); } }); }), @@ -304,7 +315,7 @@ export const storeModel: StoreModel = { state.elements.forEach((n) => { if (n.id === id && isNode(n)) { - n.__rf.position = position; + n.__rf.position = clampPosition(position, state.nodeExtent); } }); }), @@ -313,10 +324,11 @@ export const storeModel: StoreModel = { state.elements.forEach((n) => { if (isNode(n) && (id === n.id || state.selectedElements?.find((sNode) => sNode.id === n.id))) { if (diff) { - n.__rf.position = { + const position = { x: n.__rf.position.x + diff.x, y: n.__rf.position.y + diff.y, }; + n.__rf.position = clampPosition(position, state.nodeExtent); } n.__rf.isDragging = isDragging; } @@ -465,6 +477,16 @@ export const storeModel: StoreModel = { } }), + setNodeExtent: action((state, nodeExtent) => { + state.nodeExtent = nodeExtent; + + state.elements.forEach((el) => { + if (isNode(el)) { + el.__rf.position = clampPosition(el.__rf.position, nodeExtent); + } + }); + }), + setConnectionPosition: action((state, position) => { state.connectionPosition = position; }), diff --git a/src/types/index.ts b/src/types/index.ts index 11a2ffb8..888a5b00 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -339,6 +339,7 @@ export type FlowTransform = { }; export type TranslateExtent = [[number, number], [number, number]]; +export type NodeExtent = TranslateExtent; export type KeyCode = number | string; diff --git a/src/utils/graph.ts b/src/utils/graph.ts index 772fd87c..5e8c2638 100644 --- a/src/utils/graph.ts +++ b/src/utils/graph.ts @@ -1,5 +1,7 @@ import { Store } from 'easy-peasy'; + import { StoreModel } from '../store'; +import { clampPosition } from '../utils'; import { ElementId, Node, @@ -11,6 +13,7 @@ import { Box, Connection, FlowExportObject, + NodeExtent, } from '../types'; export const isEdge = (element: Node | Connection | Edge): element is Edge => @@ -142,7 +145,7 @@ export const onLoadProject = (currentStore: Store) => { }; }; -export const parseElement = (element: Node | Edge): Node | Edge => { +export const parseElement = (element: Node | Edge, nodeExtent: NodeExtent): Node | Edge => { if (!element.id) { throw new Error('All nodes and edges need to have an id.'); } @@ -164,7 +167,7 @@ export const parseElement = (element: Node | Edge): Node | Edge => { id: element.id.toString(), type: element.type || 'default', __rf: { - position: element.position, + position: clampPosition(element.position, nodeExtent), width: null, height: null, handleBounds: {}, diff --git a/src/utils/index.ts b/src/utils/index.ts index 13832a31..9ddb29aa 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,7 @@ import { DraggableEvent } from 'react-draggable'; import { MouseEvent as ReactMouseEvent } from 'react'; -import { Dimensions } from '../types'; +import { Dimensions, XYPosition, NodeExtent } from '../types'; export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEvent) => { const target = e?.target as HTMLElement; @@ -17,3 +17,8 @@ export const getDimensions = (node: HTMLDivElement): Dimensions => ({ }); export const clamp = (val: number, min: number = 0, max: number = 1): number => Math.min(Math.max(val, min), max); + +export const clampPosition = (position: XYPosition, extent: NodeExtent) => ({ + x: clamp(position.x, extent[0][0], extent[1][0]), + y: clamp(position.y, extent[0][1], extent[1][1]), +});