From 75aff8ef269281fc93e0841b809929826ef89bfc Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 15 Oct 2025 15:15:07 +0200 Subject: [PATCH 01/10] refactor(selection): make it possible to start a selection above a node #5342 --- .../examples/CustomNode/ColorSelectorNode.tsx | 2 +- packages/react/src/container/Pane/index.tsx | 43 +++++++++++++++---- packages/system/src/utils/dom.ts | 1 - 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx index 1265d408..77bce1c9 100644 --- a/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx +++ b/examples/react/src/examples/CustomNode/ColorSelectorNode.tsx @@ -30,7 +30,7 @@ function ColorSelectorNode({ data, isConnectable }: NodeProps
Custom Color Picker Node: {data.color}
- + (false); const selectionStarted = useRef(false); + const isNoKeyEvent = useRef(null); + const onClick = (event: ReactMouseEvent) => { // 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 @@ -119,20 +121,40 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; + const onMouseDownCapture = (event: ReactMouseEvent) => { + isNoKeyEvent.current = + isNoKeyEvent.current === null ? !!(event.target as HTMLElement).closest('.nokey') : isNoKeyEvent.current; + + if (isNoKeyEvent.current) { + return; + } + + event.stopPropagation(); + }; + + const onClickCapture = (event: ReactMouseEvent) => { + if (isNoKeyEvent.current) { + isNoKeyEvent.current = null; + return; + } + + isNoKeyEvent.current = null; + + event.stopPropagation(); + }; + const onPointerDown = (event: ReactPointerEvent): void => { const { resetSelectedElements, domNode } = store.getState(); containerBounds.current = domNode?.getBoundingClientRect(); + isNoKeyEvent.current = + isNoKeyEvent.current === null ? !!(event.target as HTMLElement).closest('.nokey') : isNoKeyEvent.current; - if ( - !elementsSelectable || - !isSelecting || - event.button !== 0 || - event.target !== container.current || - !containerBounds.current - ) { + if (!elementsSelectable || !isSelecting || event.button !== 0 || !containerBounds.current || isNoKeyEvent.current) { return; } + event.stopPropagation(); + (event.target as Partial | null)?.setPointerCapture?.(event.pointerId); selectionStarted.current = true; @@ -234,7 +256,7 @@ export function Pane({ (event.target as Partial)?.releasePointerCapture?.(event.pointerId); const { userSelectionRect } = store.getState(); - + isNoKeyEvent.current = null; /* * We only want to trigger click functions when in selection mode if * the user did not move the mouse. @@ -270,9 +292,12 @@ export function Pane({ onContextMenu={wrapHandler(onContextMenu, container)} onWheel={wrapHandler(onWheel, container)} onPointerEnter={hasActiveSelection ? undefined : onPaneMouseEnter} - onPointerDown={hasActiveSelection ? onPointerDown : onPaneMouseMove} + onPointerDown={hasActiveSelection ? undefined : onPaneMouseMove} onPointerMove={hasActiveSelection ? onPointerMove : onPaneMouseMove} onPointerUp={hasActiveSelection ? onPointerUp : undefined} + onPointerDownCapture={hasActiveSelection ? onPointerDown : undefined} + onMouseDownCapture={hasActiveSelection ? onMouseDownCapture : undefined} + onClickCapture={hasActiveSelection ? onClickCapture : undefined} onPointerLeave={onPaneMouseLeave} ref={container} style={containerStyle} diff --git a/packages/system/src/utils/dom.ts b/packages/system/src/utils/dom.ts index 2311153f..5f92c136 100644 --- a/packages/system/src/utils/dom.ts +++ b/packages/system/src/utils/dom.ts @@ -43,7 +43,6 @@ export function isInputDOMNode(event: KeyboardEvent): boolean { if (target?.nodeType !== 1 /* Node.ELEMENT_NODE */) return false; const isInput = inputTags.includes(target.nodeName) || target.hasAttribute('contenteditable'); - // when an input field is focused we don't want to trigger deletion or movement of nodes return isInput || !!target.closest('.nokey'); } From 3993b007821f371ac954fdbd2e1037f7a502ae75 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Wed, 15 Oct 2025 16:19:08 +0200 Subject: [PATCH 02/10] simplify logic --- packages/react/src/container/Pane/index.tsx | 26 ++++----------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 88350020..29a20f7f 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -121,39 +121,21 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; - const onMouseDownCapture = (event: ReactMouseEvent) => { - isNoKeyEvent.current = - isNoKeyEvent.current === null ? !!(event.target as HTMLElement).closest('.nokey') : isNoKeyEvent.current; - - if (isNoKeyEvent.current) { - return; - } - - event.stopPropagation(); - }; - const onClickCapture = (event: ReactMouseEvent) => { - if (isNoKeyEvent.current) { - isNoKeyEvent.current = null; - return; - } - - isNoKeyEvent.current = null; - event.stopPropagation(); }; const onPointerDown = (event: ReactPointerEvent): void => { const { resetSelectedElements, domNode } = store.getState(); containerBounds.current = domNode?.getBoundingClientRect(); - isNoKeyEvent.current = - isNoKeyEvent.current === null ? !!(event.target as HTMLElement).closest('.nokey') : isNoKeyEvent.current; + const isNoKeyEvent = event.target !== container.current && !!(event.target as HTMLElement).closest('.nokey'); - if (!elementsSelectable || !isSelecting || event.button !== 0 || !containerBounds.current || isNoKeyEvent.current) { + if (!elementsSelectable || !isSelecting || event.button !== 0 || !containerBounds.current || isNoKeyEvent) { return; } event.stopPropagation(); + event.preventDefault(); (event.target as Partial | null)?.setPointerCapture?.(event.pointerId); @@ -250,6 +232,7 @@ export function Pane({ }; const onPointerUp = (event: ReactPointerEvent) => { + console.log('onPointerUp'); if (event.button !== 0 || !selectionStarted.current) { return; } @@ -296,7 +279,6 @@ export function Pane({ onPointerMove={hasActiveSelection ? onPointerMove : onPaneMouseMove} onPointerUp={hasActiveSelection ? onPointerUp : undefined} onPointerDownCapture={hasActiveSelection ? onPointerDown : undefined} - onMouseDownCapture={hasActiveSelection ? onMouseDownCapture : undefined} onClickCapture={hasActiveSelection ? onClickCapture : undefined} onPointerLeave={onPaneMouseLeave} ref={container} From 072d10da85fe64679a2e20f28b3733b6645cde36 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 15 Oct 2025 16:28:01 +0200 Subject: [PATCH 03/10] chore(pane): cleanup --- packages/react/src/container/Pane/index.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 29a20f7f..81a8345d 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -95,8 +95,6 @@ export function Pane({ const selectionInProgress = useRef(false); const selectionStarted = useRef(false); - const isNoKeyEvent = useRef(null); - const onClick = (event: ReactMouseEvent) => { // 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 @@ -121,13 +119,12 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; - const onClickCapture = (event: ReactMouseEvent) => { - event.stopPropagation(); - }; + const onClickCapture = (event: ReactMouseEvent) => event.stopPropagation(); const onPointerDown = (event: ReactPointerEvent): void => { const { resetSelectedElements, domNode } = store.getState(); containerBounds.current = domNode?.getBoundingClientRect(); + const isNoKeyEvent = event.target !== container.current && !!(event.target as HTMLElement).closest('.nokey'); if (!elementsSelectable || !isSelecting || event.button !== 0 || !containerBounds.current || isNoKeyEvent) { @@ -232,14 +229,12 @@ export function Pane({ }; const onPointerUp = (event: ReactPointerEvent) => { - console.log('onPointerUp'); if (event.button !== 0 || !selectionStarted.current) { return; } (event.target as Partial)?.releasePointerCapture?.(event.pointerId); const { userSelectionRect } = store.getState(); - isNoKeyEvent.current = null; /* * We only want to trigger click functions when in selection mode if * the user did not move the mouse. @@ -275,7 +270,6 @@ export function Pane({ onContextMenu={wrapHandler(onContextMenu, container)} onWheel={wrapHandler(onWheel, container)} onPointerEnter={hasActiveSelection ? undefined : onPaneMouseEnter} - onPointerDown={hasActiveSelection ? undefined : onPaneMouseMove} onPointerMove={hasActiveSelection ? onPointerMove : onPaneMouseMove} onPointerUp={hasActiveSelection ? onPointerUp : undefined} onPointerDownCapture={hasActiveSelection ? onPointerDown : undefined} From 14afbe312bb7d3228d2f53653bd1f5c98e2c0952 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 15 Oct 2025 19:37:06 +0200 Subject: [PATCH 04/10] refactor(svelte): selection above nodess --- .../svelte/src/lib/container/Pane/Pane.svelte | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index e33e9a55..49ed27fb 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -94,16 +94,22 @@ function onPointerDown(event: PointerEvent) { containerBounds = container?.getBoundingClientRect(); + const isNoKeyEvent = + event.target !== container && !!(event.target as HTMLElement).closest('.nokey'); + if ( !store.elementsSelectable || !isSelecting || event.button !== 0 || - event.target !== container || - !containerBounds + !containerBounds || + isNoKeyEvent ) { return; } + event.stopPropagation(); + event.preventDefault(); + (event.target as Partial | null)?.setPointerCapture?.(event.pointerId); const { x, y } = getEventPosition(event, containerBounds); @@ -216,6 +222,8 @@ onpanecontextmenu?.({ event }); }; + + const onClickCapture = (event: MouseEvent) => event.stopPropagation(); @@ -227,10 +235,11 @@ class:dragging={store.dragging} class:selection={isSelecting} onclick={hasActiveSelection ? undefined : wrapHandler(onClick, container)} - onpointerdown={hasActiveSelection ? onPointerDown : undefined} + onpointerdowncapture={hasActiveSelection ? onPointerDown : undefined} onpointermove={hasActiveSelection ? onPointerMove : undefined} onpointerup={hasActiveSelection ? onPointerUp : undefined} oncontextmenu={wrapHandler(onContextMenu, container)} + onclickcapture={hasActiveSelection ? onClickCapture : undefined} > {@render children()} From 6bb64b3ed60f26c9ea8bc01c8d62fb9bf74cd634 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 15 Oct 2025 19:37:46 +0200 Subject: [PATCH 05/10] chore(changeset): add --- .changeset/fresh-groups-kneel.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fresh-groups-kneel.md diff --git a/.changeset/fresh-groups-kneel.md b/.changeset/fresh-groups-kneel.md new file mode 100644 index 00000000..df5ae93b --- /dev/null +++ b/.changeset/fresh-groups-kneel.md @@ -0,0 +1,7 @@ +--- +'@xyflow/svelte': patch +'@xyflow/system': patch +'@xyflow/react': patch +--- + +Allow to start a selection above a node From a16fc575c84d3bb1db9bafc84931f95bdebbfc98 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 16 Oct 2025 10:33:47 +0200 Subject: [PATCH 06/10] chore(pane): cleanup --- packages/react/src/container/Pane/index.tsx | 10 ++++------ packages/svelte/src/lib/container/Pane/Pane.svelte | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 81a8345d..9caea305 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -1,7 +1,3 @@ -/** - * The user selection rectangle gets displayed when a user drags the mouse while pressing shift - */ - import { useRef, type MouseEvent as ReactMouseEvent, @@ -121,7 +117,9 @@ export function Pane({ const onClickCapture = (event: ReactMouseEvent) => event.stopPropagation(); - const onPointerDown = (event: ReactPointerEvent): void => { + // We are using capture here in order to prevent other pointer events + // to be able to create a selection above a node or an edge + const onPointerDownCapture = (event: ReactPointerEvent): void => { const { resetSelectedElements, domNode } = store.getState(); containerBounds.current = domNode?.getBoundingClientRect(); @@ -272,7 +270,7 @@ export function Pane({ onPointerEnter={hasActiveSelection ? undefined : onPaneMouseEnter} onPointerMove={hasActiveSelection ? onPointerMove : onPaneMouseMove} onPointerUp={hasActiveSelection ? onPointerUp : undefined} - onPointerDownCapture={hasActiveSelection ? onPointerDown : undefined} + onPointerDownCapture={hasActiveSelection ? onPointerDownCapture : undefined} onClickCapture={hasActiveSelection ? onClickCapture : undefined} onPointerLeave={onPaneMouseLeave} ref={container} diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index 49ed27fb..bf271e08 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -91,7 +91,7 @@ } // We start the selection process when the user clicks down on the pane - function onPointerDown(event: PointerEvent) { + function onPointerDownCapture(event: PointerEvent) { containerBounds = container?.getBoundingClientRect(); const isNoKeyEvent = @@ -235,7 +235,7 @@ class:dragging={store.dragging} class:selection={isSelecting} onclick={hasActiveSelection ? undefined : wrapHandler(onClick, container)} - onpointerdowncapture={hasActiveSelection ? onPointerDown : undefined} + onpointerdowncapture={hasActiveSelection ? onPointerDownCapture : undefined} onpointermove={hasActiveSelection ? onPointerMove : undefined} onpointerup={hasActiveSelection ? onPointerUp : undefined} oncontextmenu={wrapHandler(onContextMenu, container)} From cefd92664d6b6dd3642ad1786f29e2acf8a16f48 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 16 Oct 2025 11:33:58 +0200 Subject: [PATCH 07/10] chore(selection): handle selectionOnDrag --- packages/react/src/container/Pane/index.tsx | 25 ++++++++++++++++--- .../svelte/src/lib/container/Pane/Pane.svelte | 7 +++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 9caea305..3dcf55bd 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -115,7 +115,15 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; - const onClickCapture = (event: ReactMouseEvent) => event.stopPropagation(); + const onClickCapture = (event: ReactMouseEvent) => { + const isSelectionOnDragActive = (selectionOnDrag && container.current === event.target) || !selectionOnDrag; + + if (!isSelectionOnDragActive) { + return; + } + + event.stopPropagation(); + }; // We are using capture here in order to prevent other pointer events // to be able to create a selection above a node or an edge @@ -124,15 +132,25 @@ export function Pane({ containerBounds.current = domNode?.getBoundingClientRect(); const isNoKeyEvent = event.target !== container.current && !!(event.target as HTMLElement).closest('.nokey'); + const isSelectionActive = + (selectionOnDrag && container.current === event.target) || !selectionOnDrag || selectionKeyPressed; - if (!elementsSelectable || !isSelecting || event.button !== 0 || !containerBounds.current || isNoKeyEvent) { + if ( + !elementsSelectable || + !isSelecting || + event.button !== 0 || + !containerBounds.current || + isNoKeyEvent || + !isSelectionActive || + !event.isPrimary + ) { return; } event.stopPropagation(); event.preventDefault(); - (event.target as Partial | null)?.setPointerCapture?.(event.pointerId); + (event.target as Partial)?.setPointerCapture?.(event.pointerId); selectionStarted.current = true; selectionInProgress.current = false; @@ -232,6 +250,7 @@ export function Pane({ } (event.target as Partial)?.releasePointerCapture?.(event.pointerId); + const { userSelectionRect } = store.getState(); /* * We only want to trigger click functions when in selection mode if diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index bf271e08..4fb5f0f3 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -102,7 +102,8 @@ !isSelecting || event.button !== 0 || !containerBounds || - isNoKeyEvent + isNoKeyEvent || + !event.isPrimary ) { return; } @@ -110,7 +111,7 @@ event.stopPropagation(); event.preventDefault(); - (event.target as Partial | null)?.setPointerCapture?.(event.pointerId); + (event.target as Partial)?.setPointerCapture?.(event.pointerId); const { x, y } = getEventPosition(event, containerBounds); @@ -192,7 +193,7 @@ return; } - (event.target as Partial | null)?.releasePointerCapture?.(event.pointerId); + (event.target as Partial)?.releasePointerCapture?.(event.pointerId); // We only want to trigger click functions when in selection mode if // the user did not move the mouse. From 353a497e71cc4856f266ef1480ee699ff0ea2ad7 Mon Sep 17 00:00:00 2001 From: peterkogo Date: Thu, 16 Oct 2025 12:09:06 +0200 Subject: [PATCH 08/10] fix selectOnDrag for Svelte Flow --- .../svelte/src/routes/examples/customnode/CustomNode.svelte | 2 +- packages/svelte/src/lib/container/Pane/Pane.svelte | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte index 6bc71e2e..30182418 100644 --- a/examples/svelte/src/routes/examples/customnode/CustomNode.svelte +++ b/examples/svelte/src/routes/examples/customnode/CustomNode.svelte @@ -13,7 +13,7 @@ Custom Color Picker Node: {bg.color} (bg.color = evt.currentTarget.value)} value={bg.color} diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index 4fb5f0f3..7b76a65e 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -97,12 +97,18 @@ const isNoKeyEvent = event.target !== container && !!(event.target as HTMLElement).closest('.nokey'); + const isSelectionActive = + (selectionOnDrag && container === event.target) || + !selectionOnDrag || + store.selectionKeyPressed; + if ( !store.elementsSelectable || !isSelecting || event.button !== 0 || !containerBounds || isNoKeyEvent || + !isSelectionActive || !event.isPrimary ) { return; From ac423f38809686ab1e0310304a1a96fba6ff06e7 Mon Sep 17 00:00:00 2001 From: moklick Date: Thu, 16 Oct 2025 15:50:48 +0200 Subject: [PATCH 09/10] chore(selection): handle selectionOnDrag in click capture --- packages/react/src/container/Pane/index.tsx | 3 ++- packages/svelte/src/lib/container/Pane/Pane.svelte | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/react/src/container/Pane/index.tsx b/packages/react/src/container/Pane/index.tsx index 3dcf55bd..ef86be3f 100644 --- a/packages/react/src/container/Pane/index.tsx +++ b/packages/react/src/container/Pane/index.tsx @@ -116,7 +116,8 @@ export function Pane({ const onWheel = onPaneScroll ? (event: React.WheelEvent) => onPaneScroll(event) : undefined; const onClickCapture = (event: ReactMouseEvent) => { - const isSelectionOnDragActive = (selectionOnDrag && container.current === event.target) || !selectionOnDrag; + const isSelectionOnDragActive = + (selectionOnDrag && container.current === event.target) || !selectionOnDrag || selectionKeyPressed; if (!isSelectionOnDragActive) { return; diff --git a/packages/svelte/src/lib/container/Pane/Pane.svelte b/packages/svelte/src/lib/container/Pane/Pane.svelte index 7b76a65e..ef77b1de 100644 --- a/packages/svelte/src/lib/container/Pane/Pane.svelte +++ b/packages/svelte/src/lib/container/Pane/Pane.svelte @@ -230,7 +230,18 @@ onpanecontextmenu?.({ event }); }; - const onClickCapture = (event: MouseEvent) => event.stopPropagation(); + const onClickCapture = (event: MouseEvent) => { + const isSelectionActive = + (selectionOnDrag && container === event.target) || + !selectionOnDrag || + store.selectionKeyPressed; + + if (!isSelectionActive) { + return; + } + + event.stopPropagation(); + }; From c688daa1b38ce7f932bfa411fd26b364eca2badb Mon Sep 17 00:00:00 2001 From: Moritz Klack Date: Thu, 16 Oct 2025 21:28:32 +0200 Subject: [PATCH 10/10] Change version types for dependencies Updated versioning for @xyflow/svelte and @xyflow/react to minor. --- .changeset/fresh-groups-kneel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/fresh-groups-kneel.md b/.changeset/fresh-groups-kneel.md index df5ae93b..776c97a1 100644 --- a/.changeset/fresh-groups-kneel.md +++ b/.changeset/fresh-groups-kneel.md @@ -1,7 +1,7 @@ --- -'@xyflow/svelte': patch +'@xyflow/svelte': minor '@xyflow/system': patch -'@xyflow/react': patch +'@xyflow/react': minor --- Allow to start a selection above a node