From 86438a954f22b61bdd6c0d886c39557eecb552b3 Mon Sep 17 00:00:00 2001 From: Peter Kogo <7165378+peterkogo@users.noreply.github.com> Date: Wed, 19 Jun 2024 17:05:04 +0200 Subject: [PATCH] Better selection usability (#4379) * use pointer capture for selection boxes * fixed selection box behaviour * promoted modifier listeners to window level * selection should still happen when shift key is let go during selection * ported changes to svelte flow * review changes --- .../src/container/FlowRenderer/index.tsx | 5 +- packages/react/src/container/Pane/index.tsx | 54 ++++++++++++------- .../react/src/hooks/useGlobalKeyHandler.ts | 2 +- .../svelte/src/lib/container/Pane/Pane.svelte | 46 +++++++++------- 4 files changed, 67 insertions(+), 40 deletions(-) diff --git a/packages/react/src/container/FlowRenderer/index.tsx b/packages/react/src/container/FlowRenderer/index.tsx index 14216ac2..1aa92c41 100644 --- a/packages/react/src/container/FlowRenderer/index.tsx +++ b/packages/react/src/container/FlowRenderer/index.tsx @@ -70,8 +70,8 @@ function FlowRendererComponent({ isControlledViewport, }: FlowRendererProps) { const { nodesSelectionActive, userSelectionActive } = useStore(selector); - const selectionKeyPressed = useKeyPress(selectionKeyCode); - const panActivationKeyPressed = useKeyPress(panActivationKeyCode); + const selectionKeyPressed = useKeyPress(selectionKeyCode, { target: window }); + const panActivationKeyPressed = useKeyPress(panActivationKeyCode, { target: window }); const panOnDrag = panActivationKeyPressed || _panOnDrag; const panOnScroll = panActivationKeyPressed || _panOnScroll; @@ -113,6 +113,7 @@ function FlowRendererComponent({ panOnDrag={panOnDrag} isSelecting={!!isSelecting} selectionMode={selectionMode} + selectionKeyPressed={selectionKeyPressed} > {children} {nodesSelectionActive && ( diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 17fe91b3..d0661307 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -2,7 +2,12 @@ * The user selection rectangle gets displayed when a user drags the mouse while pressing shift */ -import { useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; +import { + useRef, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from 'react'; import { shallow } from 'zustand/shallow'; import cc from 'classcat'; import { getNodesInside, getEventPosition, SelectionMode, type NodeChange, type EdgeChange } from '@xyflow/system'; @@ -15,6 +20,7 @@ import type { ReactFlowProps, ReactFlowState } from '../../types'; type PaneProps = { isSelecting: boolean; + selectionKeyPressed: boolean; children: ReactNode; } & Partial< Pick< @@ -52,6 +58,7 @@ const selector = (s: ReactFlowState) => ({ export function Pane({ isSelecting, + selectionKeyPressed, selectionMode = SelectionMode.Full, panOnDrag, onSelectionStart, @@ -72,6 +79,10 @@ export function Pane({ const edgeIdLookup = useRef>>(new Map()); const { userSelectionActive, elementsSelectable, dragging } = useStore(selector, shallow); + const hasActiveSelection = elementsSelectable && (isSelecting || userSelectionActive); + + // Used to prevent click events when the user lets go of the selectionKey during a selection + const selectionInProgress = useRef(false); const resetUserSelection = () => { store.setState({ userSelectionActive: false, userSelectionRect: null }); @@ -81,6 +92,12 @@ export function Pane({ }; const onClick = (event: ReactMouseEvent) => { + // We prevent click events when the user let go of the selectionKey during a selection + if (selectionInProgress.current) { + selectionInProgress.current = false; + return; + } + onPaneClick?.(event); store.getState().resetSelectedElements(); store.setState({ nodesSelectionActive: false }); @@ -97,9 +114,10 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; - const onMouseDown = (event: ReactMouseEvent): void => { + const onPointerDown = (event: ReactPointerEvent): void => { const { resetSelectedElements, domNode, edgeLookup } = store.getState(); containerBounds.current = domNode?.getBoundingClientRect(); + container.current?.setPointerCapture(event.pointerId); if ( !elementsSelectable || @@ -136,14 +154,16 @@ export function Pane({ onSelectionStart?.(event); }; - const onMouseMove = (event: ReactMouseEvent): void => { + const onPointerMove = (event: ReactPointerEvent): void => { const { userSelectionRect, edgeLookup, transform, nodeOrigin, nodeLookup, triggerNodeChanges, triggerEdgeChanges } = store.getState(); - if (!isSelecting || !containerBounds.current || !userSelectionRect) { + if (!containerBounds.current || !userSelectionRect) { return; } + selectionInProgress.current = true; + const { x: mouseX, y: mouseY } = getEventPosition(event.nativeEvent, containerBounds.current); const { startX, startY } = userSelectionRect; @@ -199,10 +219,11 @@ export function Pane({ }); }; - const onMouseUp = (event: ReactMouseEvent) => { + const onPointerUp = (event: ReactPointerEvent) => { if (event.button !== 0) { return; } + container.current?.releasePointerCapture(event.pointerId); const { userSelectionRect } = store.getState(); // We only want to trigger click functions when in selection mode if // the user did not move the mouse. @@ -214,30 +235,25 @@ export function Pane({ resetUserSelection(); onSelectionEnd?.(event); - }; - const onMouseLeave = (event: ReactMouseEvent) => { - if (userSelectionActive) { - store.setState({ nodesSelectionActive: prevSelectedNodesCount.current > 0 }); - onSelectionEnd?.(event); + // If the user kept holding the selectionKey during the selection, + // we need to reset the selectionInProgress, so the next click event is not prevented + if (selectionKeyPressed) { + selectionInProgress.current = false; } - - resetUserSelection(); }; - const hasActiveSelection = elementsSelectable && (isSelecting || userSelectionActive); - return (
diff --git a/packages/react/src/hooks/useGlobalKeyHandler.ts b/packages/react/src/hooks/useGlobalKeyHandler.ts index ffc7a2aa..8e0c34bb 100644 --- a/packages/react/src/hooks/useGlobalKeyHandler.ts +++ b/packages/react/src/hooks/useGlobalKeyHandler.ts @@ -26,7 +26,7 @@ export function useGlobalKeyHandler({ const { deleteElements } = useReactFlow(); const deleteKeyPressed = useKeyPress(deleteKeyCode, deleteKeyOptions); - const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode); + const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode, { target: window }); useEffect(() => { if (deleteKeyPressed) { diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index e616a1b4..e95ea563 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -75,15 +75,24 @@ $selectionKeyPressed || $selectionRect || (selectionOnDrag && _panOnDrag !== true); $: hasActiveSelection = $elementsSelectable && (isSelecting || $selectionRectMode === 'user'); - function onClick(event: MouseEvent | TouchEvent) { - dispatch('paneclick', { event }); + // Used to prevent click events when the user lets go of the selectionKey during a selection + let selectionInProgress = false; + function onClick(event: MouseEvent | TouchEvent) { + // We prevent click events when the user let go of the selectionKey during a selection + if (selectionInProgress) { + selectionInProgress = false; + return; + } + + dispatch('paneclick', { event }); unselectNodesAndEdges(); selectionRectMode.set(null); } - function onMouseDown(event: MouseEvent) { + function onPointerDown(event: PointerEvent) { containerBounds = container.getBoundingClientRect(); + container.setPointerCapture(event.pointerId); if ( !elementsSelectable || @@ -111,10 +120,13 @@ // onSelectionStart?.(event); } - function onMouseMove(event: MouseEvent) { + function onPointerMove(event: PointerEvent) { if (!isSelecting || !containerBounds || !$selectionRect) { return; } + + selectionInProgress = true; + const mousePos = getEventPosition(event, containerBounds); const startX = $selectionRect.startX ?? 0; const startY = $selectionRect.startY ?? 0; @@ -157,11 +169,13 @@ selectionRect.set(nextUserSelectRect); } - function onMouseUp(event: MouseEvent) { + function onPointerUp(event: PointerEvent) { if (event.button !== 0) { return; } + container.releasePointerCapture(event.pointerId); + // We only want to trigger click functions when in selection mode if // the user did not move the mouse. if (!isSelecting && $selectionRectMode === 'user' && event.target === container) { @@ -173,17 +187,14 @@ $selectionRectMode = 'nodes'; } - // onSelectionEnd?.(event); - } - - const onMouseLeave = () => { - if ($selectionRectMode === 'user') { - selectionRectMode.set(selectedNodes.length > 0 ? 'nodes' : null); - // onSelectionEnd?.(event); + // If the user kept holding the selectionKey during the selection, + // we need to reset the selectionInProgress, so the next click event is not prevented + if ($selectionKeyPressed) { + selectionInProgress = false; } - selectionRect.set(null); - }; + // onSelectionEnd?.(event); + } const onContextMenu = (event: MouseEvent) => { if (Array.isArray(_panOnDrag) && _panOnDrag?.includes(2)) { @@ -204,10 +215,9 @@ class:dragging={$dragging} class:selection={isSelecting} on:click={hasActiveSelection ? undefined : wrapHandler(onClick, container)} - on:mousedown={hasActiveSelection ? onMouseDown : undefined} - on:mousemove={hasActiveSelection ? onMouseMove : undefined} - on:mouseup={hasActiveSelection ? onMouseUp : undefined} - on:mouseleave={hasActiveSelection ? onMouseLeave : undefined} + on:pointerdown={hasActiveSelection ? onPointerDown : undefined} + on:pointermove={hasActiveSelection ? onPointerMove : undefined} + on:pointerup={hasActiveSelection ? onPointerUp : undefined} on:contextmenu={wrapHandler(onContextMenu, container)} >