Merge pull request #5572 from xyflow/fix/pane-events

Fix/pane events
This commit is contained in:
Moritz Klack
2025-10-28 16:14:54 +01:00
committed by GitHub
23 changed files with 190 additions and 153 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@xyflow/svelte': patch
---
Fix showing selection box when no nodes are selected
+7
View File
@@ -0,0 +1,7 @@
---
'@xyflow/react': patch
'@xyflow/svelte': patch
'@xyflow/system': patch
---
Fix onPaneClick events being suppressed when selectionOnDrag=true
+5
View File
@@ -0,0 +1,5 @@
---
'@xyflow/svelte': patch
---
Remove focus outline from selection box
+8 -1
View File
@@ -30,10 +30,11 @@ const BasicFlow = () => {
<ReactFlow <ReactFlow
defaultNodes={initialNodes} defaultNodes={initialNodes}
defaultEdges={initialEdges} defaultEdges={initialEdges}
selectionOnDrag selectionOnDrag={true}
selectionMode={SelectionMode.Partial} selectionMode={SelectionMode.Partial}
panOnDrag={panOnDrag} panOnDrag={panOnDrag}
panOnScroll panOnScroll
paneClickDistance={100}
zoomActivationKeyCode="Meta" zoomActivationKeyCode="Meta"
multiSelectionKeyCode={MULTI_SELECT_KEY} multiSelectionKeyCode={MULTI_SELECT_KEY}
onPaneContextMenu={onPaneContextMenu} onPaneContextMenu={onPaneContextMenu}
@@ -43,6 +44,12 @@ const BasicFlow = () => {
onMoveStart={onMoveStart} onMoveStart={onMoveStart}
onMove={onMove} onMove={onMove}
onMoveEnd={onMoveEnd} onMoveEnd={onMoveEnd}
onPaneClick={(e) => console.log('pane click', e)}
onSelectionStart={(e) => console.log('on selection start', e)}
onSelectionEnd={(e) => console.log('on selection end', e)}
onPointerDown={(e) => console.log('pointer down', e)}
onPointerUp={(e) => console.log('pointer up', e)}
onClick={(e) => console.log('click', e)}
> >
<Background variant={BackgroundVariant.Cross} /> <Background variant={BackgroundVariant.Cross} />
<Controls /> <Controls />
@@ -9,11 +9,6 @@
import '@xyflow/svelte/dist/style.css'; import '@xyflow/svelte/dist/style.css';
const onPaneContextMenu = (e: any) => {
e.preventDefault();
console.log('context menu');
};
const panOnDrag = [1, 2]; const panOnDrag = [1, 2];
const onmovestart = (e: any) => console.log('move start', e); const onmovestart = (e: any) => console.log('move start', e);
@@ -46,10 +41,14 @@
selectionMode={SelectionMode.Partial} selectionMode={SelectionMode.Partial}
selectionOnDrag selectionOnDrag
panOnScroll panOnScroll
paneClickDistance={100}
{panOnDrag} {panOnDrag}
{onmovestart} {onmovestart}
{onmove} {onmove}
{onmoveend} {onmoveend}
onpaneclick={(e) => console.log('on pane click', e)}
onselectionend={(e) => console.log('on selection end', e)}
onselectionstart={(e) => console.log('on selection start', e)}
> >
<Controls /> <Controls />
<Background variant={BackgroundVariant.Dots} /> <Background variant={BackgroundVariant.Dots} />
@@ -69,7 +69,6 @@ const reactFlowFieldsToTrack = [
'onBeforeDelete', 'onBeforeDelete',
'debug', 'debug',
'autoPanSpeed', 'autoPanSpeed',
'paneClickDistance',
'ariaLabelConfig', 'ariaLabelConfig',
] as const; ] as const;
@@ -93,7 +92,6 @@ const selector = (s: ReactFlowState) => ({
setNodeExtent: s.setNodeExtent, setNodeExtent: s.setNodeExtent,
reset: s.reset, reset: s.reset,
setDefaultNodesAndEdges: s.setDefaultNodesAndEdges, setDefaultNodesAndEdges: s.setDefaultNodesAndEdges,
setPaneClickDistance: s.setPaneClickDistance,
}); });
const initPrevValues = { const initPrevValues = {
@@ -109,7 +107,6 @@ const initPrevValues = {
elementsSelectable: true, elementsSelectable: true,
noPanClassName: 'nopan', noPanClassName: 'nopan',
rfId: '1', rfId: '1',
paneClickDistance: 0,
}; };
export function StoreUpdater<NodeType extends Node = Node, EdgeType extends Edge = Edge>( export function StoreUpdater<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
@@ -124,7 +121,6 @@ export function StoreUpdater<NodeType extends Node = Node, EdgeType extends Edge
setNodeExtent, setNodeExtent,
reset, reset,
setDefaultNodesAndEdges, setDefaultNodesAndEdges,
setPaneClickDistance,
} = useStore(selector, shallow); } = useStore(selector, shallow);
const store = useStoreApi<NodeType, EdgeType>(); const store = useStoreApi<NodeType, EdgeType>();
@@ -155,7 +151,6 @@ export function StoreUpdater<NodeType extends Node = Node, EdgeType extends Edge
else if (fieldName === 'maxZoom') setMaxZoom(fieldValue as number); else if (fieldName === 'maxZoom') setMaxZoom(fieldValue as number);
else if (fieldName === 'translateExtent') setTranslateExtent(fieldValue as CoordinateExtent); else if (fieldName === 'translateExtent') setTranslateExtent(fieldValue as CoordinateExtent);
else if (fieldName === 'nodeExtent') setNodeExtent(fieldValue as CoordinateExtent); else if (fieldName === 'nodeExtent') setNodeExtent(fieldValue as CoordinateExtent);
else if (fieldName === 'paneClickDistance') setPaneClickDistance(fieldValue as number);
else if (fieldName === 'ariaLabelConfig') else if (fieldName === 'ariaLabelConfig')
store.setState({ ariaLabelConfig: mergeAriaLabelConfig(fieldValue as AriaLabelConfig) }); store.setState({ ariaLabelConfig: mergeAriaLabelConfig(fieldValue as AriaLabelConfig) });
// Renamed fields // Renamed fields
@@ -105,6 +105,7 @@ function FlowRendererComponent<NodeType extends Node = Node>({
onViewportChange={onViewportChange} onViewportChange={onViewportChange}
isControlledViewport={isControlledViewport} isControlledViewport={isControlledViewport}
paneClickDistance={paneClickDistance} paneClickDistance={paneClickDistance}
selectionOnDrag={_selectionOnDrag}
> >
<Pane <Pane
onSelectionStart={onSelectionStart} onSelectionStart={onSelectionStart}
@@ -119,6 +120,7 @@ function FlowRendererComponent<NodeType extends Node = Node>({
isSelecting={!!isSelecting} isSelecting={!!isSelecting}
selectionMode={selectionMode} selectionMode={selectionMode}
selectionKeyPressed={selectionKeyPressed} selectionKeyPressed={selectionKeyPressed}
paneClickDistance={paneClickDistance}
selectionOnDrag={_selectionOnDrag} selectionOnDrag={_selectionOnDrag}
> >
{children} {children}
+60 -63
View File
@@ -1,19 +1,15 @@
import { import {
useRef, useRef,
type MouseEventHandler,
type MutableRefObject,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import cc from 'classcat'; import cc from 'classcat';
import { import { getNodesInside, getEventPosition, SelectionMode, areSetsEqual } from '@xyflow/system';
getNodesInside,
getEventPosition,
SelectionMode,
areSetsEqual,
type NodeChange,
type EdgeChange,
} from '@xyflow/system';
import { UserSelection } from '../../components/UserSelection'; import { UserSelection } from '../../components/UserSelection';
import { containerStyle } from '../../styles/utils'; import { containerStyle } from '../../styles/utils';
@@ -25,6 +21,7 @@ type PaneProps = {
isSelecting: boolean; isSelecting: boolean;
selectionKeyPressed: boolean; selectionKeyPressed: boolean;
children: ReactNode; children: ReactNode;
paneClickDistance: number;
} & Partial< } & Partial<
Pick< Pick<
ReactFlowProps, ReactFlowProps,
@@ -43,9 +40,9 @@ type PaneProps = {
>; >;
const wrapHandler = ( const wrapHandler = (
handler: React.MouseEventHandler | undefined, handler: MouseEventHandler | undefined,
containerRef: React.MutableRefObject<HTMLDivElement | null> containerRef: MutableRefObject<HTMLDivElement | null>
): React.MouseEventHandler => { ): MouseEventHandler => {
return (event: ReactMouseEvent) => { return (event: ReactMouseEvent) => {
if (event.target !== containerRef.current) { if (event.target !== containerRef.current) {
return; return;
@@ -66,6 +63,7 @@ export function Pane({
selectionKeyPressed, selectionKeyPressed,
selectionMode = SelectionMode.Full, selectionMode = SelectionMode.Full,
panOnDrag, panOnDrag,
paneClickDistance,
selectionOnDrag, selectionOnDrag,
onSelectionStart, onSelectionStart,
onSelectionEnd, onSelectionEnd,
@@ -79,17 +77,15 @@ export function Pane({
}: PaneProps) { }: PaneProps) {
const store = useStoreApi(); const store = useStoreApi();
const { userSelectionActive, elementsSelectable, dragging, connectionInProgress } = useStore(selector, shallow); const { userSelectionActive, elementsSelectable, dragging, connectionInProgress } = useStore(selector, shallow);
const hasActiveSelection = elementsSelectable && (isSelecting || userSelectionActive); const isSelectionEnabled = elementsSelectable && (isSelecting || userSelectionActive);
const container = useRef<HTMLDivElement | null>(null); const container = useRef<HTMLDivElement | null>(null);
const containerBounds = useRef<DOMRect>(); const containerBounds = useRef<DOMRect>();
const selectedNodeIds = useRef<Set<string>>(new Set()); const selectedNodeIds = useRef<Set<string>>(new Set());
const selectedEdgeIds = useRef<Set<string>>(new Set()); const selectedEdgeIds = useRef<Set<string>>(new Set());
// Used to prevent click events when the user lets go of the selectionKey during a selection // Used to prevent click events when the user lets go of the selectionKey during a selection
const selectionInProgress = useRef<boolean>(false); const selectionInProgress = useRef<boolean>(false);
const selectionStarted = useRef<boolean>(false);
const onClick = (event: ReactMouseEvent) => { const onClick = (event: ReactMouseEvent) => {
// We prevent click events when the user let go of the selectionKey during a selection // We prevent click events when the user let go of the selectionKey during a selection
@@ -113,17 +109,13 @@ export function Pane({
onPaneContextMenu?.(event); onPaneContextMenu?.(event);
}; };
const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; const onWheel = onPaneScroll ? (event: ReactWheelEvent) => onPaneScroll(event) : undefined;
const onClickCapture = (event: ReactMouseEvent) => { const onClickCapture = (event: ReactMouseEvent) => {
const isSelectionOnDragActive = if (selectionInProgress.current) {
(selectionOnDrag && container.current === event.target) || !selectionOnDrag || selectionKeyPressed; event.stopPropagation();
selectionInProgress.current = false;
if (!isSelectionOnDragActive) {
return;
} }
event.stopPropagation();
}; };
// We are using capture here in order to prevent other pointer events // We are using capture here in order to prevent other pointer events
@@ -131,35 +123,23 @@ export function Pane({
const onPointerDownCapture = (event: ReactPointerEvent): void => { const onPointerDownCapture = (event: ReactPointerEvent): void => {
const { resetSelectedElements, domNode } = store.getState(); const { resetSelectedElements, domNode } = store.getState();
containerBounds.current = domNode?.getBoundingClientRect(); containerBounds.current = domNode?.getBoundingClientRect();
if (!containerBounds.current) return;
const isNoKeyEvent = event.target !== container.current && !!(event.target as HTMLElement).closest('.nokey'); const eventTargetIsContainer = event.target === container.current;
const isSelectionActive = // if a child element has the 'nokey' class, we don't want to swallow the event and don't start a selection
(selectionOnDrag && container.current === event.target) || !selectionOnDrag || selectionKeyPressed; const isNoKeyEvent = !eventTargetIsContainer && !!(event.target as HTMLElement).closest('.nokey');
const isSelectionActive = (selectionOnDrag && eventTargetIsContainer) || selectionKeyPressed;
if ( if (isNoKeyEvent || !isSelecting || !isSelectionActive || event.button !== 0 || !event.isPrimary) {
!elementsSelectable ||
!isSelecting ||
event.button !== 0 ||
!containerBounds.current ||
isNoKeyEvent ||
!isSelectionActive ||
!event.isPrimary
) {
return; return;
} }
event.stopPropagation();
event.preventDefault();
(event.target as Partial<Element>)?.setPointerCapture?.(event.pointerId); (event.target as Partial<Element>)?.setPointerCapture?.(event.pointerId);
selectionStarted.current = true;
selectionInProgress.current = false; selectionInProgress.current = false;
const { x, y } = getEventPosition(event.nativeEvent, containerBounds.current); const { x, y } = getEventPosition(event.nativeEvent, containerBounds.current);
resetSelectedElements();
store.setState({ store.setState({
userSelectionRect: { userSelectionRect: {
width: 0, width: 0,
@@ -171,7 +151,17 @@ export function Pane({
}, },
}); });
onSelectionStart?.(event); if (!eventTargetIsContainer) {
event.stopPropagation();
event.preventDefault();
}
if (paneClickDistance === 0 || selectionKeyPressed) {
resetSelectedElements();
onSelectionStart?.(event);
selectionInProgress.current = true;
}
}; };
const onPointerMove = (event: ReactPointerEvent): void => { const onPointerMove = (event: ReactPointerEvent): void => {
@@ -184,17 +174,32 @@ export function Pane({
triggerNodeChanges, triggerNodeChanges,
triggerEdgeChanges, triggerEdgeChanges,
defaultEdgeOptions, defaultEdgeOptions,
resetSelectedElements,
} = store.getState(); } = store.getState();
if (!containerBounds.current || !userSelectionRect) { if (!containerBounds.current || !userSelectionRect) {
return; return;
} }
selectionInProgress.current = true;
const { x: mouseX, y: mouseY } = getEventPosition(event.nativeEvent, containerBounds.current); const { x: mouseX, y: mouseY } = getEventPosition(event.nativeEvent, containerBounds.current);
const { startX, startY } = userSelectionRect; const { startX, startY } = userSelectionRect;
if (
!selectionInProgress.current &&
event.target === container.current &&
!selectionKeyPressed &&
paneClickDistance > 0
) {
const distance = Math.hypot(mouseX - startX, mouseY - startY);
if (distance <= paneClickDistance) {
return;
}
resetSelectedElements();
onSelectionStart?.(event);
}
selectionInProgress.current = true;
const nextUserSelectRect = { const nextUserSelectRect = {
startX, startX,
startY, startY,
@@ -229,12 +234,12 @@ export function Pane({
} }
if (!areSetsEqual(prevSelectedNodeIds, selectedNodeIds.current)) { if (!areSetsEqual(prevSelectedNodeIds, selectedNodeIds.current)) {
const changes = getSelectionChanges(nodeLookup, selectedNodeIds.current, true) as NodeChange[]; const changes = getSelectionChanges(nodeLookup, selectedNodeIds.current, true);
triggerNodeChanges(changes); triggerNodeChanges(changes);
} }
if (!areSetsEqual(prevSelectedEdgeIds, selectedEdgeIds.current)) { if (!areSetsEqual(prevSelectedEdgeIds, selectedEdgeIds.current)) {
const changes = getSelectionChanges(edgeLookup, selectedEdgeIds.current) as EdgeChange[]; const changes = getSelectionChanges(edgeLookup, selectedEdgeIds.current);
triggerEdgeChanges(changes); triggerEdgeChanges(changes);
} }
@@ -246,18 +251,17 @@ export function Pane({
}; };
const onPointerUp = (event: ReactPointerEvent) => { const onPointerUp = (event: ReactPointerEvent) => {
if (event.button !== 0 || !selectionStarted.current) { if (event.button !== 0) {
return; return;
} }
(event.target as Partial<Element>)?.releasePointerCapture?.(event.pointerId); (event.target as Partial<Element>)?.releasePointerCapture?.(event.pointerId);
const { userSelectionRect } = store.getState();
/* /*
* We only want to trigger click functions when in selection mode if * We only want to trigger click functions when in selection mode if
* the user did not move the mouse. * the user did not move the mouse.
*/ */
if (!userSelectionActive && userSelectionRect && event.target === container.current) { if (!userSelectionActive && event.target === container.current && store.getState().userSelectionRect) {
onClick?.(event); onClick?.(event);
} }
@@ -266,17 +270,10 @@ export function Pane({
userSelectionRect: null, userSelectionRect: null,
nodesSelectionActive: selectedNodeIds.current.size > 0, nodesSelectionActive: selectedNodeIds.current.size > 0,
}); });
onSelectionEnd?.(event);
/* if (selectionInProgress.current) {
* If the user kept holding the selectionKey during the selection, onSelectionEnd?.(event);
* we need to reset the selectionInProgress, so the next click event is not prevented
*/
if (selectionKeyPressed || selectionOnDrag) {
selectionInProgress.current = false;
} }
selectionStarted.current = false;
}; };
const draggable = panOnDrag === true || (Array.isArray(panOnDrag) && panOnDrag.includes(0)); const draggable = panOnDrag === true || (Array.isArray(panOnDrag) && panOnDrag.includes(0));
@@ -284,14 +281,14 @@ export function Pane({
return ( return (
<div <div
className={cc(['react-flow__pane', { draggable, dragging, selection: isSelecting }])} className={cc(['react-flow__pane', { draggable, dragging, selection: isSelecting }])}
onClick={hasActiveSelection ? undefined : wrapHandler(onClick, container)} onClick={isSelectionEnabled ? undefined : wrapHandler(onClick, container)}
onContextMenu={wrapHandler(onContextMenu, container)} onContextMenu={wrapHandler(onContextMenu, container)}
onWheel={wrapHandler(onWheel, container)} onWheel={wrapHandler(onWheel, container)}
onPointerEnter={hasActiveSelection ? undefined : onPaneMouseEnter} onPointerEnter={isSelectionEnabled ? undefined : onPaneMouseEnter}
onPointerMove={hasActiveSelection ? onPointerMove : onPaneMouseMove} onPointerMove={isSelectionEnabled ? onPointerMove : onPaneMouseMove}
onPointerUp={hasActiveSelection ? onPointerUp : undefined} onPointerUp={isSelectionEnabled ? onPointerUp : undefined}
onPointerDownCapture={hasActiveSelection ? onPointerDownCapture : undefined} onPointerDownCapture={isSelectionEnabled ? onPointerDownCapture : undefined}
onClickCapture={hasActiveSelection ? onClickCapture : undefined} onClickCapture={isSelectionEnabled ? onClickCapture : undefined}
onPointerLeave={onPaneMouseLeave} onPointerLeave={onPaneMouseLeave}
ref={container} ref={container}
style={containerStyle} style={containerStyle}
@@ -309,7 +309,6 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
nodeDragThreshold={nodeDragThreshold} nodeDragThreshold={nodeDragThreshold}
connectionDragThreshold={connectionDragThreshold} connectionDragThreshold={connectionDragThreshold}
onBeforeDelete={onBeforeDelete} onBeforeDelete={onBeforeDelete}
paneClickDistance={paneClickDistance}
debug={debug} debug={debug}
ariaLabelConfig={ariaLabelConfig} ariaLabelConfig={ariaLabelConfig}
/> />
@@ -12,12 +12,7 @@ import type { ReactFlowState } from '../../types';
type ZoomPaneProps = Omit< type ZoomPaneProps = Omit<
FlowRendererProps, FlowRendererProps,
| 'deleteKeyCode' 'deleteKeyCode' | 'selectionKeyCode' | 'multiSelectionKeyCode' | 'noDragClassName' | 'disableKeyboardA11y'
| 'selectionKeyCode'
| 'multiSelectionKeyCode'
| 'noDragClassName'
| 'disableKeyboardA11y'
| 'selectionOnDrag'
> & { > & {
isControlledViewport: boolean; isControlledViewport: boolean;
}; };
@@ -49,6 +44,7 @@ export function ZoomPane({
onViewportChange, onViewportChange,
isControlledViewport, isControlledViewport,
paneClickDistance, paneClickDistance,
selectionOnDrag,
}: ZoomPaneProps) { }: ZoomPaneProps) {
const store = useStoreApi(); const store = useStoreApi();
const zoomPane = useRef<HTMLDivElement>(null); const zoomPane = useRef<HTMLDivElement>(null);
@@ -77,7 +73,6 @@ export function ZoomPane({
maxZoom, maxZoom,
translateExtent, translateExtent,
viewport: defaultViewport, viewport: defaultViewport,
paneClickDistance,
onDraggingChange: (paneDragging: boolean) => store.setState({ paneDragging }), onDraggingChange: (paneDragging: boolean) => store.setState({ paneDragging }),
onPanZoomStart: (event, vp) => { onPanZoomStart: (event, vp) => {
const { onViewportChangeStart, onMoveStart } = store.getState(); const { onViewportChangeStart, onMoveStart } = store.getState();
@@ -128,6 +123,8 @@ export function ZoomPane({
lib, lib,
onTransformChange, onTransformChange,
connectionInProgress, connectionInProgress,
selectionOnDrag,
paneClickDistance,
}); });
}, [ }, [
onPaneContextMenu, onPaneContextMenu,
@@ -146,6 +143,8 @@ export function ZoomPane({
lib, lib,
onTransformChange, onTransformChange,
connectionInProgress, connectionInProgress,
selectionOnDrag,
paneClickDistance,
]); ]);
return ( return (
-3
View File
@@ -312,9 +312,6 @@ const createStore = ({
set({ translateExtent }); set({ translateExtent });
}, },
setPaneClickDistance: (clickDistance) => {
get().panZoom?.setClickDistance(clickDistance);
},
resetSelectedElements: () => { resetSelectedElements: () => {
const { edges, nodes, triggerNodeChanges, triggerEdgeChanges, elementsSelectable } = get(); const { edges, nodes, triggerNodeChanges, triggerEdgeChanges, elementsSelectable } = get();
-1
View File
@@ -175,7 +175,6 @@ export type ReactFlowActions<NodeType extends Node, EdgeType extends Edge> = {
triggerEdgeChanges: (changes: EdgeChange<EdgeType>[]) => void; triggerEdgeChanges: (changes: EdgeChange<EdgeType>[]) => void;
panBy: PanBy; panBy: PanBy;
setCenter: SetCenter; setCenter: SetCenter;
setPaneClickDistance: (distance: number) => void;
}; };
export type ReactFlowState<NodeType extends Node = Node, EdgeType extends Edge = Edge> = ReactFlowStore< export type ReactFlowState<NodeType extends Node = Node, EdgeType extends Edge = Edge> = ReactFlowStore<
@@ -35,6 +35,7 @@ type ZoomParams = {
userSelectionActive: boolean; userSelectionActive: boolean;
lib: string; lib: string;
paneClickDistance: number; paneClickDistance: number;
selectionOnDrag?: boolean;
onTransformChange: (transform: Transform) => void; onTransformChange: (transform: Transform) => void;
onDraggingChange: (dragging: boolean) => void; onDraggingChange: (dragging: boolean) => void;
connectionInProgress: boolean; connectionInProgress: boolean;
@@ -49,7 +50,6 @@ export default function zoom(domNode: Element, params: ZoomParams) {
onPanZoom, onPanZoom,
onPanZoomEnd, onPanZoomEnd,
translateExtent, translateExtent,
paneClickDistance,
setPanZoomInstance, setPanZoomInstance,
onDraggingChange, onDraggingChange,
onTransformChange onTransformChange
@@ -61,7 +61,6 @@ export default function zoom(domNode: Element, params: ZoomParams) {
maxZoom, maxZoom,
translateExtent, translateExtent,
viewport: initialViewport, viewport: initialViewport,
paneClickDistance,
onPanZoom, onPanZoom,
onPanZoomStart, onPanZoomStart,
onPanZoomEnd, onPanZoomEnd,
@@ -31,7 +31,12 @@
if (store.selectionRectMode === 'nodes') { if (store.selectionRectMode === 'nodes') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
store.nodes; store.nodes;
return getInternalNodesBounds(store.nodeLookup, { filter: (node) => !!node.selected }); const nodeBounds = getInternalNodesBounds(store.nodeLookup, {
filter: (node) => !!node.selected
});
if (nodeBounds.width > 0 && nodeBounds.height > 0) {
return nodeBounds;
}
} }
return null; return null;
}); });
@@ -92,4 +97,9 @@
z-index: 2000; z-index: 2000;
pointer-events: all; pointer-events: all;
} }
.svelte-flow__selection-wrapper:focus,
.svelte-flow__selection-wrapper:focus-visible {
outline: none;
}
</style> </style>
@@ -47,6 +47,7 @@
let { let {
store = $bindable(), store = $bindable(),
panOnDrag = true, panOnDrag = true,
paneClickDistance = 1,
selectionOnDrag, selectionOnDrag,
onpaneclick, onpaneclick,
onpanecontextmenu, onpanecontextmenu,
@@ -67,61 +68,44 @@
let panOnDragActive = $derived(store.panActivationKeyPressed || panOnDrag); let panOnDragActive = $derived(store.panActivationKeyPressed || panOnDrag);
let isSelecting = $derived( let isSelecting = $derived(
store.selectionKeyPressed || store.selectionKeyPressed ||
store.selectionRect || !!store.selectionRect ||
(selectionOnDrag && panOnDragActive !== true) (selectionOnDrag && panOnDragActive !== true)
); );
let hasActiveSelection = $derived( let isSelectionEnabled = $derived(
store.elementsSelectable && (isSelecting || store.selectionRectMode === 'user') store.elementsSelectable && (isSelecting || store.selectionRectMode === 'user')
); );
// Used to prevent click events when the user lets go of the selectionKey during a selection // Used to prevent click events when the user lets go of the selectionKey during a selection
let selectionInProgress = false; let selectionInProgress = false;
function onClick(event: MouseEvent) {
// We prevent click events when the user let go of the selectionKey during a selection
// We also prevent click events when a connection is in progress
if (selectionInProgress || store.connection.inProgress) {
selectionInProgress = false;
return;
}
onpaneclick?.({ event });
store.unselectNodesAndEdges();
store.selectionRectMode = null;
}
// We start the selection process when the user clicks down on the pane // We start the selection process when the user clicks down on the pane
function onPointerDownCapture(event: PointerEvent) { function onPointerDownCapture(event: PointerEvent) {
containerBounds = container?.getBoundingClientRect(); containerBounds = container?.getBoundingClientRect();
if (!containerBounds) return;
const eventTargetIsContainer = event.target === container;
const isNoKeyEvent = const isNoKeyEvent =
event.target !== container && !!(event.target as HTMLElement).closest('.nokey'); !eventTargetIsContainer && !!(event.target as HTMLElement).closest('.nokey');
const isSelectionActive = const isSelectionActive =
(selectionOnDrag && container === event.target) || (selectionOnDrag && eventTargetIsContainer) || store.selectionKeyPressed;
!selectionOnDrag ||
store.selectionKeyPressed;
if ( if (
!store.elementsSelectable ||
!isSelecting ||
event.button !== 0 ||
!containerBounds ||
isNoKeyEvent || isNoKeyEvent ||
!isSelecting ||
!isSelectionActive || !isSelectionActive ||
event.button !== 0 ||
!event.isPrimary !event.isPrimary
) { ) {
return; return;
} }
event.stopPropagation();
event.preventDefault();
(event.target as Partial<Element>)?.setPointerCapture?.(event.pointerId); (event.target as Partial<Element>)?.setPointerCapture?.(event.pointerId);
const { x, y } = getEventPosition(event, containerBounds); selectionInProgress = false;
store.unselectNodesAndEdges(); const { x, y } = getEventPosition(event, containerBounds);
store.selectionRect = { store.selectionRect = {
width: 0, width: 0,
@@ -132,7 +116,17 @@
y y
}; };
onselectionstart?.(event); if (!eventTargetIsContainer) {
event.stopPropagation();
event.preventDefault();
}
if (paneClickDistance === 0 || store.selectionKeyPressed) {
store.unselectNodesAndEdges();
onselectionstart?.(event);
selectionInProgress = true;
}
} }
function onPointerMove(event: PointerEvent) { function onPointerMove(event: PointerEvent) {
@@ -140,11 +134,25 @@
return; return;
} }
selectionInProgress = true;
const mousePos = getEventPosition(event, containerBounds); const mousePos = getEventPosition(event, containerBounds);
const { startX = 0, startY = 0 } = store.selectionRect; const { startX = 0, startY = 0 } = store.selectionRect;
if (
!selectionInProgress &&
event.target === container &&
!store.selectionKeyPressed &&
paneClickDistance > 0
) {
const distance = Math.hypot(mousePos.x - startX, mousePos.y - startY);
if (distance <= paneClickDistance) {
return;
}
store.unselectNodesAndEdges();
onselectionstart?.(event);
}
selectionInProgress = true;
const nextUserSelectRect = { const nextUserSelectRect = {
...store.selectionRect, ...store.selectionRect,
x: mousePos.x < startX ? mousePos.x : startX, x: mousePos.x < startX ? mousePos.x : startX,
@@ -203,22 +211,20 @@
// We only want to trigger click functions when in selection mode if // We only want to trigger click functions when in selection mode if
// the user did not move the mouse. // the user did not move the mouse.
if (!isSelecting && store.selectionRectMode === 'user' && event.target === container) {
if (!selectionInProgress && event.target === container) {
onClick?.(event); onClick?.(event);
} }
store.selectionRect = null; store.selectionRect = null;
if (selectedNodeIds.size > 0) { if (selectionInProgress) {
store.selectionRectMode = 'nodes'; store.selectionRectMode = selectedNodeIds.size > 0 ? 'nodes' : null;
} }
// If the user kept holding the selectionKey during the selection, if (selectionInProgress) {
// we need to reset the selectionInProgress, so the next click event is not prevented onselectionend?.(event);
if (store.selectionKeyPressed) {
selectionInProgress = false;
} }
onselectionend?.(event);
} }
const onContextMenu = (event: MouseEvent) => { const onContextMenu = (event: MouseEvent) => {
@@ -231,17 +237,25 @@
}; };
const onClickCapture = (event: MouseEvent) => { const onClickCapture = (event: MouseEvent) => {
const isSelectionActive = if (selectionInProgress) {
(selectionOnDrag && container === event.target) || event.stopPropagation();
!selectionOnDrag || selectionInProgress = false;
store.selectionKeyPressed; }
};
if (!isSelectionActive) { function onClick(event: MouseEvent) {
// We prevent click events when the user let go of the selectionKey during a selection
// We also prevent click events when a connection is in progress
if (selectionInProgress || store.connection.inProgress) {
selectionInProgress = false;
return; return;
} }
event.stopPropagation(); onpaneclick?.({ event });
}; store.unselectNodesAndEdges();
store.selectionRectMode = null;
store.selectionRect = null;
}
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -252,12 +266,12 @@
class:draggable={panOnDrag === true || (Array.isArray(panOnDrag) && panOnDrag.includes(0))} class:draggable={panOnDrag === true || (Array.isArray(panOnDrag) && panOnDrag.includes(0))}
class:dragging={store.dragging} class:dragging={store.dragging}
class:selection={isSelecting} class:selection={isSelecting}
onclick={hasActiveSelection ? undefined : wrapHandler(onClick, container)} onclick={isSelectionEnabled ? undefined : wrapHandler(onClick, container)}
onpointerdowncapture={hasActiveSelection ? onPointerDownCapture : undefined} onpointerdowncapture={isSelectionEnabled ? onPointerDownCapture : undefined}
onpointermove={hasActiveSelection ? onPointerMove : undefined} onpointermove={isSelectionEnabled ? onPointerMove : undefined}
onpointerup={hasActiveSelection ? onPointerUp : undefined} onpointerup={isSelectionEnabled ? onPointerUp : undefined}
oncontextmenu={wrapHandler(onContextMenu, container)} oncontextmenu={wrapHandler(onContextMenu, container)}
onclickcapture={hasActiveSelection ? onClickCapture : undefined} onclickcapture={isSelectionEnabled ? onClickCapture : undefined}
> >
{@render children()} {@render children()}
</div> </div>
@@ -5,6 +5,7 @@ import type { SvelteFlowStore } from '$lib/store/types';
export type PaneProps<NodeType extends Node = Node, EdgeType extends Edge = Edge> = { export type PaneProps<NodeType extends Node = Node, EdgeType extends Edge = Edge> = {
store: SvelteFlowStore<NodeType, EdgeType>; store: SvelteFlowStore<NodeType, EdgeType>;
panOnDrag?: boolean | number[]; panOnDrag?: boolean | number[];
paneClickDistance: number;
selectionOnDrag?: boolean; selectionOnDrag?: boolean;
onselectionstart?: (event: PointerEvent) => void; onselectionstart?: (event: PointerEvent) => void;
onselectionend?: (event: PointerEvent) => void; onselectionend?: (event: PointerEvent) => void;
@@ -156,6 +156,7 @@
{panOnScrollSpeed} {panOnScrollSpeed}
{panOnDrag} {panOnDrag}
{paneClickDistance} {paneClickDistance}
{selectionOnDrag}
{onmovestart} {onmovestart}
{onmove} {onmove}
{onmoveend} {onmoveend}
@@ -168,6 +169,7 @@
{onselectionstart} {onselectionstart}
{onselectionend} {onselectionend}
{panOnDrag} {panOnDrag}
{paneClickDistance}
{selectionOnDrag} {selectionOnDrag}
> >
<ViewportComponent bind:store> <ViewportComponent bind:store>
@@ -16,6 +16,7 @@
panOnScroll, panOnScroll,
panOnScrollSpeed, panOnScrollSpeed,
paneClickDistance, paneClickDistance,
selectionOnDrag,
onmovestart, onmovestart,
onmove, onmove,
onmoveend, onmoveend,
@@ -69,6 +70,7 @@
translateExtent: store.translateExtent, translateExtent: store.translateExtent,
lib: 'svelte', lib: 'svelte',
paneClickDistance, paneClickDistance,
selectionOnDrag,
onTransformChange: (transform: Transform) => { onTransformChange: (transform: Transform) => {
store.viewport = { x: transform[0], y: transform[1], zoom: transform[2] }; store.viewport = { x: transform[0], y: transform[1], zoom: transform[2] };
}, },
@@ -14,6 +14,7 @@ export type ZoomProps<NodeType extends Node = Node, EdgeType extends Edge = Edge
panOnScroll: boolean; panOnScroll: boolean;
panOnDrag: boolean | number[]; panOnDrag: boolean | number[];
paneClickDistance: number; paneClickDistance: number;
selectionOnDrag?: boolean;
onmove?: OnMove; onmove?: OnMove;
onmovestart?: OnMoveStart; onmovestart?: OnMoveStart;
onmoveend?: OnMoveEnd; onmoveend?: OnMoveEnd;
-5
View File
@@ -191,10 +191,6 @@ export function createStore<NodeType extends Node = Node, EdgeType extends Edge
} }
} }
function setPaneClickDistance(distance: number) {
store.panZoom?.setClickDistance(distance);
}
function deselect<T extends Node | Edge>( function deselect<T extends Node | Edge>(
elements: T[], elements: T[],
elementsToDeselect: Set<string> | null = null elementsToDeselect: Set<string> | null = null
@@ -393,7 +389,6 @@ export function createStore<NodeType extends Node = Node, EdgeType extends Edge
setMinZoom, setMinZoom,
setMaxZoom, setMaxZoom,
setTranslateExtent, setTranslateExtent,
setPaneClickDistance,
unselectNodesAndEdges, unselectNodesAndEdges,
addSelectedNodes, addSelectedNodes,
addSelectedEdges, addSelectedEdges,
-1
View File
@@ -23,7 +23,6 @@ export type SvelteFlowStoreActions<NodeType extends Node = Node, EdgeType extend
setMinZoom: (minZoom: number) => void; setMinZoom: (minZoom: number) => void;
setMaxZoom: (maxZoom: number) => void; setMaxZoom: (maxZoom: number) => void;
setTranslateExtent: (extent: CoordinateExtent) => void; setTranslateExtent: (extent: CoordinateExtent) => void;
setPaneClickDistance: (distance: number) => void;
fitView: (options?: FitViewOptions) => Promise<boolean>; fitView: (options?: FitViewOptions) => Promise<boolean>;
setCenter: SetCenter; setCenter: SetCenter;
updateNodePositions: UpdateNodePositions; updateNodePositions: UpdateNodePositions;
+2 -1
View File
@@ -9,7 +9,6 @@ export type PanZoomParams = {
domNode: Element; domNode: Element;
minZoom: number; minZoom: number;
maxZoom: number; maxZoom: number;
paneClickDistance: number;
viewport: Viewport; viewport: Viewport;
translateExtent: CoordinateExtent; translateExtent: CoordinateExtent;
onDraggingChange: OnDraggingChange; onDraggingChange: OnDraggingChange;
@@ -43,6 +42,8 @@ export type PanZoomUpdateOptions = {
lib: string; lib: string;
onTransformChange: OnTransformChange; onTransformChange: OnTransformChange;
connectionInProgress: boolean; connectionInProgress: boolean;
paneClickDistance: number;
selectionOnDrag?: boolean;
}; };
export type PanZoomInstance = { export type PanZoomInstance = {
+7 -5
View File
@@ -37,7 +37,6 @@ export function XYPanZoom({
domNode, domNode,
minZoom, minZoom,
maxZoom, maxZoom,
paneClickDistance,
translateExtent, translateExtent,
viewport, viewport,
onPanZoom, onPanZoom,
@@ -55,10 +54,7 @@ export function XYPanZoom({
isPanScrolling: false, isPanScrolling: false,
}; };
const bbox = domNode.getBoundingClientRect(); const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom() const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
.clickDistance(!isNumeric(paneClickDistance) || paneClickDistance < 0 ? 0 : paneClickDistance)
.scaleExtent([minZoom, maxZoom])
.translateExtent(translateExtent);
const d3Selection = select(domNode).call(d3ZoomInstance); const d3Selection = select(domNode).call(d3ZoomInstance);
setViewportConstrained( setViewportConstrained(
@@ -109,6 +105,8 @@ export function XYPanZoom({
lib, lib,
onTransformChange, onTransformChange,
connectionInProgress, connectionInProgress,
paneClickDistance,
selectionOnDrag,
}: PanZoomUpdateOptions) { }: PanZoomUpdateOptions) {
if (userSelectionActive && !zoomPanValues.isZoomingOrPanning) { if (userSelectionActive && !zoomPanValues.isZoomingOrPanning) {
destroy(); destroy();
@@ -116,6 +114,10 @@ export function XYPanZoom({
const isPanOnScroll = panOnScroll && !zoomActivationKeyPressed && !userSelectionActive; const isPanOnScroll = panOnScroll && !zoomActivationKeyPressed && !userSelectionActive;
d3ZoomInstance.clickDistance(
selectionOnDrag ? Infinity : !isNumeric(paneClickDistance) || paneClickDistance < 0 ? 0 : paneClickDistance
);
const wheelHandler = isPanOnScroll const wheelHandler = isPanOnScroll
? createPanOnScrollHandler({ ? createPanOnScrollHandler({
zoomPanValues, zoomPanValues,