diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 024a22ac..6dffe3b0 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -1,6 +1,7 @@ import Basic from '../examples/Basic'; import Backgrounds from '../examples/Backgrounds'; import ColorMode from '../examples/ColorMode'; +import ClickDistance from '../examples/ClickDistance'; import ControlledUncontrolled from '../examples/ControlledUncontrolled'; import ControlledViewport from '../examples/ControlledViewport'; import CustomConnectionLine from '../examples/CustomConnectionLine'; @@ -85,6 +86,11 @@ const routes: IRoute[] = [ path: 'cancel-connection', component: CancelConnection, }, + { + name: 'Click Distance', + path: 'click-distance', + component: ClickDistance, + }, { name: 'Controlled/Uncontrolled', path: 'controlled-uncontrolled', diff --git a/examples/react/src/examples/ClickDistance/index.tsx b/examples/react/src/examples/ClickDistance/index.tsx new file mode 100644 index 00000000..97fa015b --- /dev/null +++ b/examples/react/src/examples/ClickDistance/index.tsx @@ -0,0 +1,72 @@ +import { useCallback, useState } from 'react'; +import { ReactFlow, addEdge, Node, Connection, Edge, useNodesState, useEdgesState, Panel } from '@xyflow/react'; + +const initNodes: Node[] = [ + { + id: '1a', + type: 'input', + data: { label: 'Node 1' }, + position: { x: 250, y: 5 }, + className: 'light', + ariaLabel: 'Input Node 1', + }, + { + id: '2a', + data: { label: 'Node 2' }, + position: { x: 100, y: 100 }, + className: 'light', + ariaLabel: 'Default Node 2', + }, + { + id: '3a', + data: { label: 'Node 3' }, + position: { x: 400, y: 100 }, + className: 'light', + }, + { + id: '4a', + data: { label: 'Node 4' }, + position: { x: 400, y: 200 }, + className: 'light', + }, +]; + +const initEdges: Edge[] = [ + { id: 'e1-2', source: '1a', target: '2a', ariaLabel: undefined }, + { id: 'e1-3', source: '1a', target: '3a' }, +]; + +const onPaneClick = () => console.log('pane click'); + +const BasicFlow = () => { + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges); + const [paneClickDistance, setPaneClickDistance] = useState(0); + + const onConnect = useCallback((params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)), [setEdges]); + + return ( + + + setPaneClickDistance(+evt.target.value)} + /> + click distance: {paneClickDistance} + + + ); +}; + +export default BasicFlow; diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 674eebd1..120db624 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -67,6 +67,7 @@ const reactFlowFieldsToTrack = [ 'onBeforeDelete', 'debug', 'autoPanSpeed', + 'paneClickDistance', ] as const; type ReactFlowFieldsToTrack = (typeof reactFlowFieldsToTrack)[number]; @@ -89,6 +90,7 @@ const selector = (s: ReactFlowState) => ({ setNodeExtent: s.setNodeExtent, reset: s.reset, setDefaultNodesAndEdges: s.setDefaultNodesAndEdges, + setPaneClickDistance: s.setPaneClickDistance, }); const initPrevValues = { @@ -102,6 +104,7 @@ const initPrevValues = { elementsSelectable: true, noPanClassName: 'nopan', rfId: '1', + paneClickDistance: 0, }; export function StoreUpdater( @@ -116,6 +119,7 @@ export function StoreUpdater(); @@ -146,6 +150,7 @@ export function StoreUpdater({ onPaneMouseLeave, onPaneContextMenu, onPaneScroll, + paneClickDistance, deleteKeyCode, selectionKeyCode, selectionOnDrag, @@ -101,6 +102,7 @@ function FlowRendererComponent({ noPanClassName={noPanClassName} onViewportChange={onViewportChange} isControlledViewport={isControlledViewport} + paneClickDistance={paneClickDistance} > > & { rfId: string; @@ -84,6 +85,7 @@ function GraphViewComponent( onPaneMouseLeave, onPaneScroll, onPaneContextMenu, + paneClickDistance = 0, children, onReconnect, onReconnectStart, @@ -200,6 +201,7 @@ function ReactFlow( onPaneMouseLeave={onPaneMouseLeave} onPaneScroll={onPaneScroll} onPaneContextMenu={onPaneContextMenu} + paneClickDistance={paneClickDistance} onSelectionContextMenu={onSelectionContextMenu} onSelectionStart={onSelectionStart} onSelectionEnd={onSelectionEnd} @@ -277,6 +279,7 @@ function ReactFlow( selectNodesOnDrag={selectNodesOnDrag} nodeDragThreshold={nodeDragThreshold} onBeforeDelete={onBeforeDelete} + paneClickDistance={paneClickDistance} debug={debug} /> diff --git a/packages/react/src/container/ZoomPane/index.tsx b/packages/react/src/container/ZoomPane/index.tsx index f6e7a79c..3d4003e0 100644 --- a/packages/react/src/container/ZoomPane/index.tsx +++ b/packages/react/src/container/ZoomPane/index.tsx @@ -47,6 +47,7 @@ export function ZoomPane({ noPanClassName, onViewportChange, isControlledViewport, + paneClickDistance, }: ZoomPaneProps) { const store = useStoreApi(); const zoomPane = useRef(null); @@ -64,6 +65,7 @@ export function ZoomPane({ maxZoom, translateExtent, viewport: defaultViewport, + paneClickDistance, onTransformChange: (transform: Transform) => { onViewportChange?.({ x: transform[0], y: transform[1], zoom: transform[2] }); diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 062340b7..3d3eab93 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -255,6 +255,9 @@ const createStore = ({ set({ translateExtent }); }, + setPaneClickDistance: (clickDistance) => { + get().panZoom?.setClickDistance(clickDistance); + }, resetSelectedElements: () => { const { edges, nodes, triggerNodeChanges, triggerEdgeChanges } = get(); diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index cd46ac22..29919931 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -223,6 +223,10 @@ export interface ReactFlowProps void; /** This event handler gets called when mouse leaves the pane */ onPaneMouseLeave?: (event: ReactMouseEvent) => void; + /** Distance that the mouse can move between mousedown/up that will trigger a click + * @default 0 + */ + paneClickDistance?: number; /** This handler gets called before the user deletes nodes or edges and provides a way to abort the deletion by returning false. */ onBeforeDelete?: OnBeforeDelete; /** Custom node types to be available in a flow. diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index 0e89d5c6..66bf3c44 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -170,6 +170,7 @@ export type ReactFlowActions = { panBy: PanBy; fitView: (options?: FitViewOptions) => Promise; fitViewSync: (options?: FitViewOptions) => boolean; + setPaneClickDistance: (distance: number) => void; }; export type ReactFlowState = ReactFlowStore< diff --git a/packages/svelte/src/lib/actions/zoom/index.ts b/packages/svelte/src/lib/actions/zoom/index.ts index b96e012f..a0c83c31 100644 --- a/packages/svelte/src/lib/actions/zoom/index.ts +++ b/packages/svelte/src/lib/actions/zoom/index.ts @@ -35,11 +35,20 @@ type ZoomParams = { noWheelClassName: string; userSelectionActive: boolean; lib: string; + paneClickDistance: number; }; export default function zoom(domNode: Element, params: ZoomParams) { - const { panZoom, minZoom, maxZoom, initialViewport, viewport, dragging, translateExtent } = - params; + const { + panZoom, + minZoom, + maxZoom, + initialViewport, + viewport, + dragging, + translateExtent, + paneClickDistance + } = params; const panZoomInstance = XYPanZoom({ domNode, @@ -47,6 +56,7 @@ export default function zoom(domNode: Element, params: ZoomParams) { maxZoom, translateExtent, viewport: initialViewport, + paneClickDistance, onTransformChange: (transform) => viewport.set({ x: transform[0], y: transform[1], zoom: transform[2] }), onDraggingChange: dragging.set diff --git a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte index 74709680..f19e003c 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/SvelteFlow.svelte @@ -79,6 +79,7 @@ export let onbeforedelete: $$Props['onbeforedelete'] = undefined; export let oninit: $$Props['oninit'] = undefined; export let nodeOrigin: $$Props['nodeOrigin'] = undefined; + export let paneClickDistance: $$Props['paneClickDistance'] = 0; export let defaultMarkerColor = '#b1b1b7'; @@ -125,7 +126,8 @@ edgeTypes, minZoom, maxZoom, - translateExtent + translateExtent, + paneClickDistance }); return () => { @@ -188,7 +190,8 @@ edgeTypes, minZoom, maxZoom, - translateExtent + translateExtent, + paneClickDistance }); $: colorModeClass = useColorModeClass(colorMode); @@ -225,6 +228,7 @@ zoomOnPinch={zoomOnPinch === undefined ? true : zoomOnPinch} panOnScroll={panOnScroll === undefined ? false : panOnScroll} panOnDrag={panOnDrag === undefined ? true : panOnDrag} + paneClickDistance={paneClickDistance === undefined ? 0 : paneClickDistance} > & { * @default 1 */ nodeDragThreshold?: number; + /** Distance that the mouse can move between mousedown/up that will trigger a click + * @default 0 + */ + paneClickDistance?: number; /** Minimum zoom level * @default 0.5 */ diff --git a/packages/svelte/src/lib/container/SvelteFlow/utils.ts b/packages/svelte/src/lib/container/SvelteFlow/utils.ts index c7166b0c..d7a71a7c 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/utils.ts +++ b/packages/svelte/src/lib/container/SvelteFlow/utils.ts @@ -1,7 +1,8 @@ +import type { Writable } from 'svelte/store'; +import type { CoordinateExtent } from '@xyflow/system'; + import type { SvelteFlowStore } from '$lib/store/types'; import type { EdgeTypes, NodeTypes } from '$lib/types'; -import type { CoordinateExtent } from '@xyflow/system'; -import type { Writable } from 'svelte/store'; // this is helper function for updating the store // for props where we need to call a specific store action @@ -12,13 +13,15 @@ export function updateStore( edgeTypes, minZoom, maxZoom, - translateExtent + translateExtent, + paneClickDistance }: { nodeTypes?: NodeTypes; edgeTypes?: EdgeTypes; minZoom?: number; maxZoom?: number; translateExtent?: CoordinateExtent; + paneClickDistance?: number; } ) { if (nodeTypes !== undefined) { @@ -40,6 +43,10 @@ export function updateStore( if (translateExtent !== undefined) { store.setTranslateExtent(translateExtent); } + + if (paneClickDistance !== undefined) { + store.setPaneClickDistance(paneClickDistance); + } } const getKeys = (obj: T) => Object.keys(obj) as Array; diff --git a/packages/svelte/src/lib/container/Zoom/Zoom.svelte b/packages/svelte/src/lib/container/Zoom/Zoom.svelte index 205f0f2d..adb4463d 100644 --- a/packages/svelte/src/lib/container/Zoom/Zoom.svelte +++ b/packages/svelte/src/lib/container/Zoom/Zoom.svelte @@ -19,6 +19,7 @@ export let zoomOnPinch: $$Props['zoomOnPinch']; export let panOnDrag: $$Props['panOnDrag']; export let panOnScroll: $$Props['panOnScroll']; + export let paneClickDistance: $$Props['paneClickDistance']; const { viewport, @@ -68,7 +69,8 @@ noWheelClassName: 'nowheel', userSelectionActive: !!$selectionRect, translateExtent: $translateExtent, - lib: $lib + lib: $lib, + paneClickDistance }} > diff --git a/packages/svelte/src/lib/container/Zoom/types.ts b/packages/svelte/src/lib/container/Zoom/types.ts index cf2e15cd..b7b4f29b 100644 --- a/packages/svelte/src/lib/container/Zoom/types.ts +++ b/packages/svelte/src/lib/container/Zoom/types.ts @@ -12,4 +12,5 @@ export type ZoomProps = { zoomOnPinch: boolean; panOnScroll: boolean; panOnDrag: boolean | number[]; + paneClickDistance: number; }; diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts index bae08a24..9ed41bc0 100644 --- a/packages/svelte/src/lib/store/index.ts +++ b/packages/svelte/src/lib/store/index.ts @@ -248,6 +248,10 @@ export function createStore({ return elementsChanged; } + function setPaneClickDistance(distance: number) { + get(store.panZoom)?.setClickDistance(distance); + } + function unselectNodesAndEdges(params?: { nodes?: Node[]; edges?: Edge[] }) { const resetNodes = resetSelectedElements(params?.nodes || get(store.nodes)); if (resetNodes) store.nodes.set(get(store.nodes)); @@ -448,6 +452,7 @@ export function createStore({ setMinZoom, setMaxZoom, setTranslateExtent, + setPaneClickDistance, unselectNodesAndEdges, addSelectedNodes, addSelectedEdges, diff --git a/packages/svelte/src/lib/store/types.ts b/packages/svelte/src/lib/store/types.ts index 0d8c01bf..88ee8be0 100644 --- a/packages/svelte/src/lib/store/types.ts +++ b/packages/svelte/src/lib/store/types.ts @@ -25,6 +25,7 @@ export type SvelteFlowStoreActions = { setMinZoom: (minZoom: number) => void; setMaxZoom: (maxZoom: number) => void; setTranslateExtent: (extent: CoordinateExtent) => void; + setPaneClickDistance: (distance: number) => void; fitView: (options?: FitViewOptions) => Promise; updateNodePositions: UpdateNodePositions; updateNodeInternals: (updates: Map) => void; diff --git a/packages/system/src/types/panzoom.ts b/packages/system/src/types/panzoom.ts index 4a126a98..561714b4 100644 --- a/packages/system/src/types/panzoom.ts +++ b/packages/system/src/types/panzoom.ts @@ -9,6 +9,7 @@ export type PanZoomParams = { domNode: Element; minZoom: number; maxZoom: number; + paneClickDistance: number; viewport: Viewport; translateExtent: CoordinateExtent; onTransformChange: OnTransformChange; @@ -56,4 +57,5 @@ export type PanZoomInstance = { scaleTo: (scale: number, options?: PanZoomTransformOptions) => Promise; scaleBy: (factor: number, options?: PanZoomTransformOptions) => Promise; syncViewport: (viewport: Viewport) => void; + setClickDistance: (distance: number) => void; }; diff --git a/packages/system/src/xypanzoom/XYPanZoom.ts b/packages/system/src/xypanzoom/XYPanZoom.ts index 2db56b10..3266b8e7 100644 --- a/packages/system/src/xypanzoom/XYPanZoom.ts +++ b/packages/system/src/xypanzoom/XYPanZoom.ts @@ -9,7 +9,7 @@ import { PanZoomParams, PanZoomInstance, } from '../types'; -import { clamp } from '../utils'; +import { clamp, isNumeric } from '../utils'; import { getD3Transition, viewportToTransform, wheelDelta } from './utils'; import { createPanOnScrollHandler, @@ -34,6 +34,7 @@ export function XYPanZoom({ domNode, minZoom, maxZoom, + paneClickDistance, translateExtent, viewport, onPanZoom, @@ -52,7 +53,10 @@ export function XYPanZoom({ isPanScrolling: false, }; const bbox = domNode.getBoundingClientRect(); - const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent); + const d3ZoomInstance = zoom() + .clickDistance(paneClickDistance) + .scaleExtent([minZoom, maxZoom]) + .translateExtent(translateExtent); const d3Selection = select(domNode).call(d3ZoomInstance); setViewportConstrained( @@ -267,6 +271,20 @@ export function XYPanZoom({ d3ZoomInstance?.translateExtent(translateExtent); } + function setClickDistance(distance: number) { + let validNumber = distance; + + if (!isNumeric(distance)) { + validNumber = 0; + } + + if (validNumber < 0) { + validNumber = 0; + } + + d3ZoomInstance?.clickDistance(validNumber); + } + return { update, destroy, @@ -278,5 +296,6 @@ export function XYPanZoom({ setScaleExtent, setTranslateExtent, syncViewport, + setClickDistance, }; }