diff --git a/README.md b/README.md index 3b068061..b02e62e7 100644 --- a/README.md +++ b/README.md @@ -509,8 +509,8 @@ The React Flow wrapper has the className `react-flow`. If you want to change the ### Used Class Names * `.react-flow` - Outer container -* `.react-flow__renderer` - Inner container -* `.react-flow__zoompane` - Zoom & pan pane +* `.react-flow__renderer` - Flow container, used for zooming and panning +* `.react-flow__pane` - Background Pane for click and wheel events, previously `.react-flow__zoompane` * `.react-flow__selectionpane` - Selection pane * `.react-flow__selection` - User selection * `.react-flow__edges` - Edges wrapper diff --git a/cypress/integration/flow/basic.spec.js b/cypress/integration/flow/basic.spec.js index 27e27b56..2d39054f 100644 --- a/cypress/integration/flow/basic.spec.js +++ b/cypress/integration/flow/basic.spec.js @@ -119,7 +119,7 @@ describe('Basic Flow Rendering', () => { // for d3 we have to pass the window to the event // https://github.com/cypress-io/cypress/issues/3441 cy.window().then((win) => { - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .trigger('mousedown', 'topLeft', { which: 1, view: win }) .trigger('mousemove', 'bottomLeft') .trigger('mouseup', { force: true, view: win }) @@ -133,7 +133,7 @@ describe('Basic Flow Rendering', () => { it('zooms the pane', () => { const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .trigger('wheel', 'topLeft', { deltaY: -200 }) .then(() => { const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); diff --git a/cypress/integration/flow/controls.spec.js b/cypress/integration/flow/controls.spec.js index 3975fadb..98e0def9 100644 --- a/cypress/integration/flow/controls.spec.js +++ b/cypress/integration/flow/controls.spec.js @@ -36,7 +36,7 @@ describe('Controls Testing', () => { // for d3 we have to pass the window to the event // https://github.com/cypress-io/cypress/issues/3441 cy.window().then((win) => { - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .trigger('mousedown', 'topLeft', { which: 1, view: win }) .trigger('mousemove', 'bottomLeft') .trigger('mouseup', { force: true, view: win }) diff --git a/cypress/integration/flow/empty.spec.js b/cypress/integration/flow/empty.spec.js index 300dc850..52c32b16 100644 --- a/cypress/integration/flow/empty.spec.js +++ b/cypress/integration/flow/empty.spec.js @@ -16,6 +16,8 @@ describe('Empty Flow Rendering', () => { .trigger('mousedown', 'topLeft', { which: 1, force: true }) .trigger('mousemove', 'bottomLeft', { which: 1 }) .trigger('mouseup', 'bottomLeft', { force: true }); + + cy.get('body').type('{shift}', { release: true }); }); it('renders an empty mini map', () => { diff --git a/cypress/integration/flow/interaction.spec.js b/cypress/integration/flow/interaction.spec.js index 8846aade..5810c485 100644 --- a/cypress/integration/flow/interaction.spec.js +++ b/cypress/integration/flow/interaction.spec.js @@ -52,7 +52,7 @@ describe('Interaction Flow Rendering', () => { it('tries to zoom by scroll', () => { const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .dblclick() .then(() => { const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); @@ -63,7 +63,7 @@ describe('Interaction Flow Rendering', () => { it('tries to zoom by double click', () => { const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .trigger('wheel', 'topLeft', { deltaY: -200 }) .then(() => { const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); @@ -122,7 +122,7 @@ describe('Interaction Flow Rendering', () => { it('zooms by scroll', () => { const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .trigger('wheel', 'topLeft', { deltaY: 200 }) .then(() => { const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); @@ -137,7 +137,7 @@ describe('Interaction Flow Rendering', () => { it('zooms by double click', () => { const styleBeforeZoom = Cypress.$('.react-flow__nodes').css('transform'); - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .dblclick() .then(() => { const styleAfterZoom = Cypress.$('.react-flow__nodes').css('transform'); diff --git a/cypress/integration/flow/minimap.spec.js b/cypress/integration/flow/minimap.spec.js index 1ce5bb5a..c9235ea8 100644 --- a/cypress/integration/flow/minimap.spec.js +++ b/cypress/integration/flow/minimap.spec.js @@ -19,7 +19,7 @@ describe('Minimap Testing', () => { const viewBoxBeforeZoom = Cypress.$('.react-flow__minimap').attr('viewBox'); const maskPathBeforeZoom = Cypress.$('.react-flow__minimap-mask').attr('d'); - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .trigger('wheel', 'topLeft', { deltaY: -200 }) .then(() => { const viewBoxAfterZoom = Cypress.$('.react-flow__minimap').attr('viewBox'); @@ -53,7 +53,7 @@ describe('Minimap Testing', () => { // for d3 we have to pass the window to the event // https://github.com/cypress-io/cypress/issues/3441 cy.window().then((win) => { - cy.get('.react-flow__zoompane') + cy.get('.react-flow__renderer') .trigger('mousedown', 'topLeft', { which: 1, view: win }) .trigger('mousemove', 'bottomLeft') .trigger('mouseup', { force: true, view: win }) diff --git a/example/src/Overview/index.js b/example/src/Overview/index.js index d5703ead..1f1f230d 100644 --- a/example/src/Overview/index.js +++ b/example/src/Overview/index.js @@ -4,6 +4,9 @@ import ReactFlow, { removeElements, addEdge, MiniMap, Controls, Background, isNo const onNodeDragStart = (event, node) => console.log('drag start', node); const onNodeDragStop = (event, node) => console.log('drag stop', node); +const onPaneClick = (event) => console.log('pane click', event); +const onPaneScroll = (event) => console.log('pane scroll', event); +const onPaneContextMenu = (event) => console.log('pane context menu', event); const onSelectionDrag = (event, nodes) => console.log('selection drag', nodes); const onSelectionDragStart = (event, nodes) => console.log('selection drag start', nodes); const onSelectionDragStop = (event, nodes) => console.log('selection drag stop', nodes); @@ -125,6 +128,9 @@ const OverviewFlow = () => { onElementClick={onElementClick} onElementsRemove={onElementsRemove} onConnect={onConnect} + onPaneClick={onPaneClick} + onPaneScroll={onPaneScroll} + onPaneContextMenu={onPaneContextMenu} onNodeDragStart={onNodeDragStart} onNodeDragStop={onNodeDragStop} onSelectionDragStart={onSelectionDragStart} diff --git a/src/components/UserSelection/index.tsx b/src/components/UserSelection/index.tsx index 4cb0238a..14a8a13a 100644 --- a/src/components/UserSelection/index.tsx +++ b/src/components/UserSelection/index.tsx @@ -12,7 +12,7 @@ type UserSelectionProps = { }; function getMousePosition(event: React.MouseEvent): XYPosition | void { - const reactFlowNode = document.querySelector('.react-flow'); + const reactFlowNode = (event.target as Element).closest('.react-flow'); if (!reactFlowNode) { return; } @@ -51,6 +51,7 @@ export default memo(({ selectionKeyPressed }: UserSelectionProps) => { const setUserSelection = useStoreActions((actions) => actions.setUserSelection); const updateUserSelection = useStoreActions((actions) => actions.updateUserSelection); const unsetUserSelection = useStoreActions((actions) => actions.unsetUserSelection); + const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); const renderUserSelectionPane = selectionActive || selectionKeyPressed; if (!elementsSelectable || !renderUserSelectionPane) { @@ -81,12 +82,18 @@ export default memo(({ selectionKeyPressed }: UserSelectionProps) => { const onMouseUp = () => unsetUserSelection(); + const onMouseLeave = () => { + unsetUserSelection(); + unsetNodesSelection(); + }; + return (
diff --git a/src/container/FlowRenderer/index.tsx b/src/container/FlowRenderer/index.tsx new file mode 100644 index 00000000..da3f8e1b --- /dev/null +++ b/src/container/FlowRenderer/index.tsx @@ -0,0 +1,113 @@ +import React, { useCallback, useRef, memo, ReactNode, WheelEvent, MouseEvent } from 'react'; +import { useStoreActions, useStoreState } from '../../store/hooks'; + +import useResizeHandler from '../../hooks/useResizeHandler'; +import useGlobalKeyHandler from '../../hooks/useGlobalKeyHandler'; +import useD3Zoom from '../../hooks/useD3Zoom'; +import useKeyPress from '../../hooks/useKeyPress'; + +import { GraphViewProps } from '../GraphView'; +import UserSelection from '../../components/UserSelection'; +import NodesSelection from '../../components/NodesSelection'; + +interface FlowRendererProps + extends Omit< + GraphViewProps, + | 'elements' + | 'snapToGrid' + | 'nodeTypes' + | 'edgeTypes' + | 'snapGrid' + | 'connectionLineType' + | 'arrowHeadColor' + | 'onlyRenderVisibleNodes' + > { + children: ReactNode; +} + +const FlowRenderer = ({ + children, + onPaneClick, + onPaneContextMenu, + onPaneScroll, + onElementsRemove, + deleteKeyCode, + onMove, + onMoveStart, + onMoveEnd, + selectionKeyCode, + zoomOnScroll, + zoomOnDoubleClick, + paneMoveable, + defaultPosition, + defaultZoom, + translateExtent, + onSelectionDragStart, + onSelectionDrag, + onSelectionDragStop, + onSelectionContextMenu, +}: FlowRendererProps) => { + const zoomPane = useRef(null); + const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); + const nodesSelectionActive = useStoreState((state) => state.nodesSelectionActive); + const selectionKeyPressed = useKeyPress(selectionKeyCode); + + useResizeHandler(zoomPane); + useGlobalKeyHandler({ onElementsRemove, deleteKeyCode }); + + useD3Zoom({ + zoomPane, + onMove, + onMoveStart, + onMoveEnd, + selectionKeyPressed, + zoomOnScroll, + zoomOnDoubleClick, + paneMoveable, + defaultPosition, + defaultZoom, + translateExtent, + }); + + const onClick = useCallback( + (event: MouseEvent) => { + onPaneClick?.(event); + unsetNodesSelection(); + }, + [onPaneClick] + ); + + const onContextMenu = useCallback( + (event: MouseEvent) => { + onPaneContextMenu?.(event); + }, + [onPaneContextMenu] + ); + + const onWheel = useCallback( + (event: WheelEvent) => { + onPaneScroll?.(event); + }, + [onPaneScroll] + ); + + return ( +
+ {children} + + {nodesSelectionActive && ( + + )} +
+
+ ); +}; + +FlowRenderer.displayName = 'FlowRenderer'; + +export default memo(FlowRenderer); diff --git a/src/container/GraphView/index.tsx b/src/container/GraphView/index.tsx index 321f2853..0dcafc0f 100644 --- a/src/container/GraphView/index.tsx +++ b/src/container/GraphView/index.tsx @@ -1,15 +1,10 @@ -import React, { useEffect, useRef, useCallback, memo, CSSProperties, MouseEvent, WheelEvent } from 'react'; +import React, { useEffect, useRef, memo, CSSProperties, MouseEvent, WheelEvent } from 'react'; import { useStoreState, useStoreActions, useStore } from '../../store/hooks'; +import FlowRenderer from '../FlowRenderer'; import NodeRenderer from '../NodeRenderer'; import EdgeRenderer from '../EdgeRenderer'; -import UserSelection from '../../components/UserSelection'; -import NodesSelection from '../../components/NodesSelection'; -import useKeyPress from '../../hooks/useKeyPress'; -import useD3Zoom from '../../hooks/useD3Zoom'; -import useGlobalKeyHandler from '../../hooks/useGlobalKeyHandler'; import useElementUpdater from '../../hooks/useElementUpdater'; -import useResizeHandler from '../../hooks/useResizeHandler'; import { onLoadProject, onLoadGetElements } from '../../utils/graph'; import { Elements, @@ -130,11 +125,7 @@ const GraphView = ({ onPaneContextMenu, }: GraphViewProps) => { const isInitialised = useRef(false); - const zoomPane = useRef(null); - const rendererNode = useRef(null); const d3Initialised = useStoreState((state) => state.d3Initialised); - const nodesSelectionActive = useStoreState((state) => state.nodesSelectionActive); - const unsetNodesSelection = useStoreActions((actions) => actions.unsetNodesSelection); const setOnConnect = useStoreActions((actions) => actions.setOnConnect); const setOnConnectStart = useStoreActions((actions) => actions.setOnConnectStart); const setOnConnectStop = useStoreActions((actions) => actions.setOnConnectStop); @@ -153,26 +144,8 @@ const GraphView = ({ const zoomTo = useStoreActions((actions) => actions.zoomTo); const currentStore = useStore(); - useResizeHandler(rendererNode); - useGlobalKeyHandler({ onElementsRemove, deleteKeyCode }); useElementUpdater(elements); - const selectionKeyPressed = useKeyPress(selectionKeyCode); - - useD3Zoom({ - zoomPane, - onMove, - onMoveStart, - onMoveEnd, - selectionKeyPressed, - zoomOnScroll, - zoomOnDoubleClick, - paneMoveable, - defaultPosition, - defaultZoom, - translateExtent, - }); - useEffect(() => { if (!isInitialised.current && d3Initialised) { if (onLoad) { @@ -192,28 +165,6 @@ const GraphView = ({ } }, [d3Initialised, onLoad]); - const onZoomPaneClick = useCallback( - (event: React.MouseEvent) => { - onPaneClick?.(event); - unsetNodesSelection(); - }, - [onPaneClick] - ); - - const onZoomPaneContextMenu = useCallback( - (event: React.MouseEvent) => { - onPaneContextMenu?.(event); - }, - [onPaneContextMenu] - ); - - const onZoomPaneScroll = useCallback( - (event: WheelEvent) => { - onPaneScroll?.(event); - }, - [onPaneScroll] - ); - useEffect(() => { if (onConnect) { setOnConnect(onConnect); @@ -287,7 +238,27 @@ const GraphView = ({ }, [translateExtent]); return ( -
+ - - {nodesSelectionActive && ( - - )} -
-
+
); }; diff --git a/src/hooks/useD3Zoom.ts b/src/hooks/useD3Zoom.ts index f8dabf8b..48f15ffc 100644 --- a/src/hooks/useD3Zoom.ts +++ b/src/hooks/useD3Zoom.ts @@ -107,6 +107,20 @@ export default ({ useEffect(() => { if (d3Zoom) { d3Zoom.filter((event: any) => { + if (selectionKeyPressed) { + return false; + } + + // only allow zoom on nodes + if (event.target.closest('.react-flow__node') && event.type !== 'wheel') { + return false; + } + + // only allow zoom on user selection + if (event.target.closest('.react-flow__nodesselection') && event.type !== 'wheel') { + return false; + } + if (!paneMoveable) { return false; } @@ -122,5 +136,5 @@ export default ({ return !event.ctrlKey && !event.button; }); } - }, [d3Zoom, zoomOnScroll, zoomOnDoubleClick, paneMoveable]); + }, [d3Zoom, zoomOnScroll, zoomOnDoubleClick, paneMoveable, selectionKeyPressed]); }; diff --git a/src/style.css b/src/style.css index 00a965d2..ab251497 100644 --- a/src/style.css +++ b/src/style.css @@ -6,7 +6,7 @@ } .react-flow__renderer, -.react-flow__zoompane, +.react-flow__pane, .react-flow__selectionpane { width: 100%; height: 100%; @@ -15,12 +15,16 @@ left: 0; } -.react-flow__zoompane { +.react-flow__pane { z-index: 1; } +.react-flow__renderer { + z-index: 4; +} + .react-flow__selectionpane { - z-index: 2; + z-index: 5; } .react-flow__selection {