diff --git a/README.md b/README.md index a1d06b9c..9fee03ef 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ const BasicFlow = () => ; - `minZoom`: default: `0.5` - `maxZoom`: default: `2` - `defaultZoom`: default: `1` +- `defaultPosition`: default: `[0, 0]` - `snapToGrid`: default: `false` - `snapGrid`: [x, y] array - default: `[16, 16]` - `onlyRenderVisibleNodes`: default: `true` @@ -88,7 +89,9 @@ const BasicFlow = () => ; - `onNodeContextMenu(evt: MouseEvent, node: Node)`: node context menu - `onConnect({ source, target })`: called when user connects two nodes - `onLoad(reactFlowInstance)`: called after flow is initialized -- `onMove()`: called when user pans or zooms +- `onMove()`: called when user is panning or zooming +- `onMoveStart()`: called when user starts panning or zooming +- `onMoveEnd()`: called when user ends panning or zooming - `onSelectionChange(elements: Elements)`: called when user selects one or multiple elements #### Interaction diff --git a/example/src/Overview/index.js b/example/src/Overview/index.js index 6d834393..d2247c38 100644 --- a/example/src/Overview/index.js +++ b/example/src/Overview/index.js @@ -11,6 +11,8 @@ const onLoad = (reactFlowInstance) => { reactFlowInstance.fitView(); }; +const onMoveEnd = (transform) => console.log('zoom/move end', transform); + const initialElements = [ { id: '1', @@ -116,6 +118,7 @@ const OverviewFlow = () => { onNodeDragStart={onNodeDragStart} onNodeDragStop={onNodeDragStop} onSelectionChange={onSelectionChange} + onMoveEnd={onMoveEnd} style={{ width: '100%', height: '100%' }} onLoad={onLoad} connectionLineStyle={{ stroke: '#ddd' }} diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index f76d83c7..080d3921 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -21,6 +21,7 @@ import { Edge, Connection, ConnectionLineType, + FlowTransform, } from '../../types'; export interface GraphViewProps { @@ -35,7 +36,9 @@ export interface GraphViewProps { onNodeDragStop?: (node: Node) => void; onConnect?: (connection: Connection | Edge) => void; onLoad?: OnLoadFunc; - onMove?: () => void; + onMove?: (flowTransform?: FlowTransform) => void; + onMoveStart?: (flowTransform?: FlowTransform) => void; + onMoveEnd?: (flowTransform?: FlowTransform) => void; selectionKeyCode: number; nodeTypes: NodeTypesType; edgeTypes: EdgeTypesType; @@ -52,6 +55,7 @@ export interface GraphViewProps { minZoom: number; maxZoom: number; defaultZoom: number; + defaultPosition: [number, number]; arrowHeadColor: string; markerEndId?: string; zoomOnScroll: boolean; @@ -63,6 +67,8 @@ const GraphView = memo( nodeTypes, edgeTypes, onMove, + onMoveStart, + onMoveEnd, onLoad, onElementClick, onNodeMouseEnter, @@ -88,6 +94,7 @@ const GraphView = memo( minZoom, maxZoom, defaultZoom, + defaultPosition, arrowHeadColor, markerEndId, zoomOnScroll, @@ -106,7 +113,7 @@ const GraphView = memo( const setNodesDraggable = useStoreActions((actions) => actions.setNodesDraggable); const setNodesConnectable = useStoreActions((actions) => actions.setNodesConnectable); const setElementsSelectable = useStoreActions((actions) => actions.setElementsSelectable); - const updateTransform = useStoreActions((actions) => actions.updateTransform); + const setInitTransform = useStoreActions((actions) => actions.setInitTransform); const setMinMaxZoom = useStoreActions((actions) => actions.setMinMaxZoom); const fitView = useStoreActions((actions) => actions.fitView); const zoom = useStoreActions((actions) => actions.zoom); @@ -139,10 +146,6 @@ const GraphView = memo( setOnConnect(onConnect); } - if (defaultZoom !== 1) { - updateTransform({ x: 0, y: 0, k: defaultZoom }); - } - if (rendererNode.current) { resizeObserver = new ResizeObserver((entries) => { for (let _ of entries) { @@ -162,7 +165,15 @@ const GraphView = memo( }; }, []); - useD3Zoom({ zoomPane, onMove, selectionKeyPressed, zoomOnScroll, zoomOnDoubleClick }); + useD3Zoom({ + zoomPane, + onMove, + onMoveStart, + onMoveEnd, + selectionKeyPressed, + zoomOnScroll, + zoomOnDoubleClick, + }); useEffect(() => { if (d3Initialised && onLoad) { @@ -174,6 +185,18 @@ const GraphView = memo( getElements, }); } + + if (d3Initialised) { + const initialTransform = { + x: defaultPosition[0], + y: defaultPosition[1], + k: defaultZoom, + }; + + if (initialTransform.x !== 0 || initialTransform.y !== 0 || initialTransform.k !== 1) { + setInitTransform(initialTransform); + } + } }, [d3Initialised, onLoad]); useEffect(() => { diff --git a/src/container/ReactFlow/index.tsx b/src/container/ReactFlow/index.tsx index ea4c2a01..c69b4a69 100644 --- a/src/container/ReactFlow/index.tsx +++ b/src/container/ReactFlow/index.tsx @@ -26,6 +26,7 @@ import { Edge, Connection, ConnectionLineType, + FlowTransform, } from '../../types'; import '../../style.css'; @@ -42,7 +43,9 @@ export interface ReactFlowProps extends Omit, 'on onNodeDragStop?: (node: Node) => void; onConnect?: (connection: Edge | Connection) => void; onLoad?: OnLoadFunc; - onMove?: () => void; + onMove?: (flowTransform?: FlowTransform) => void; + onMoveStart?: (flowTransform?: FlowTransform) => void; + onMoveEnd?: (flowTransform?: FlowTransform) => void; onSelectionChange?: (elements: Elements | null) => void; nodeTypes: NodeTypesType; edgeTypes: EdgeTypesType; @@ -60,6 +63,7 @@ export interface ReactFlowProps extends Omit, 'on minZoom: number; maxZoom: number; defaultZoom: number; + defaultPosition: [number, number]; arrowHeadColor: string; markerEndId?: string; zoomOnScroll: boolean; @@ -76,6 +80,8 @@ const ReactFlow = ({ edgeTypes, onLoad, onMove, + onMoveStart, + onMoveEnd, onElementsRemove, onConnect, onNodeMouseEnter, @@ -99,6 +105,7 @@ const ReactFlow = ({ minZoom, maxZoom, defaultZoom, + defaultPosition, arrowHeadColor, markerEndId, zoomOnScroll, @@ -114,6 +121,8 @@ const ReactFlow = ({ ; selectionKeyPressed: boolean; zoomOnScroll: boolean; zoomOnDoubleClick: boolean; - onMove?: () => void; + onMove?: (flowTransform?: FlowTransform) => void; + onMoveStart?: (flowTransform?: FlowTransform) => void; + onMoveEnd?: (flowTransform?: FlowTransform) => void; } -export default ({ zoomPane, onMove, zoomOnScroll, zoomOnDoubleClick, selectionKeyPressed }: UseD3ZoomParams): void => { +const viewChanged = (prevTransform: FlowTransform, eventTransform: any): boolean => + prevTransform.x !== eventTransform.x || + prevTransform.y !== eventTransform.y || + prevTransform.zoom !== eventTransform.k; + +const eventToFlowTransform = (eventTransform: any): FlowTransform => ({ + x: eventTransform.x, + y: eventTransform.y, + zoom: eventTransform.k, +}); + +export default ({ + zoomPane, + onMove, + onMoveStart, + onMoveEnd, + zoomOnScroll, + zoomOnDoubleClick, + selectionKeyPressed, +}: UseD3ZoomParams): void => { + const prevTransform = useRef({ x: 0, y: 0, zoom: 0 }); const d3Zoom = useStoreState((s) => s.d3Zoom); const initD3 = useStoreActions((actions) => actions.initD3); @@ -32,12 +55,47 @@ export default ({ zoomPane, onMove, zoomOnScroll, zoomOnDoubleClick, selectionKe updateTransform(event.transform); if (onMove) { - onMove(); + const flowTransform = eventToFlowTransform(event.transform); + onMove(flowTransform); } }); } } - }, [selectionKeyPressed, d3Zoom]); + }, [selectionKeyPressed, d3Zoom, updateTransform, onMove]); + + useEffect(() => { + if (d3Zoom) { + if (onMoveStart) { + d3Zoom.on('start', () => { + if (viewChanged(prevTransform.current, event.transform)) { + const flowTransform = eventToFlowTransform(event.transform); + prevTransform.current = flowTransform; + + onMoveStart(flowTransform); + } + }); + } else { + d3Zoom.on('start', null); + } + } + }, [d3Zoom, onMoveStart]); + + useEffect(() => { + if (d3Zoom) { + if (onMoveEnd) { + d3Zoom.on('end', () => { + if (viewChanged(prevTransform.current, event.transform)) { + const flowTransform = eventToFlowTransform(event.transform); + prevTransform.current = flowTransform; + + onMoveEnd(flowTransform); + } + }); + } else { + d3Zoom.on('end', null); + } + } + }, [d3Zoom, onMoveEnd]); useEffect(() => { if (d3Zoom) { diff --git a/src/store/index.ts b/src/store/index.ts index 0f42280f..7729f9cd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -105,6 +105,8 @@ export interface StoreModel { updateTransform: Action; + setInitTransform: Action; + updateSize: Action; initD3: Action; @@ -342,6 +344,18 @@ export const storeModel: StoreModel = { state.transform[2] = transform.k; }), + setInitTransform: action((state, transform) => { + state.transform[0] = transform.x; + state.transform[1] = transform.y; + state.transform[2] = transform.k; + + if (state.d3Selection) { + const updatedTransform = zoomIdentity.translate(transform.x, transform.y).scale(transform.k); + // we need to sync the d3 zoom transform with the updated transform + state.d3Selection.property('__zoom', updatedTransform); + } + }), + updateSize: action((state, size) => { state.width = size.width; state.height = size.height; diff --git a/src/types/index.ts b/src/types/index.ts index ecf91b8c..a362abaf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -241,3 +241,9 @@ export type NodePosUpdate = { id: ElementId; pos: XYPosition; }; + +export type FlowTransform = { + x: number; + y: number; + zoom: number; +};