Thanks to visit codestin.com
Credit goes to Github.com

Skip to content

Conversation

@peterkogo
Copy link
Member

@peterkogo peterkogo commented Oct 20, 2025

closes #5204 and to an extent #4996

2 things changed:

  • We calculate the nodeClickDistance ourselves in clientCoordinates
  • We always prevent clicks when a node position has changed

This leads to a much better default especially in connection with snapGrid. You can simply set the nodeClickDistance to Infinity which leads to nodeClickEvents always firing when the node has not moved. This way you don't even have to rely on some arbitrary number but can just let your snapGrid control it implicitly.

I would argue you NEVER want to fire click events after dragging has commenced. We might want to check this assumption based on the how things like draggable handle this. This is the same behavior as draggable html elements have.

The question remains if we shouldn't just remove nodeClickDistance, because nodeDragThreshold should be the sole value controlling this behavior.

Summary by CodeRabbit

  • Bug Fixes

    • More reliably distinguish clicks from drags to prevent accidental activations.
    • Suppress unintended clicks during or immediately after dragging and ensure the click blocker is removed on teardown.
    • Capture initial pointer position for smoother, more accurate drag detection.
  • New Features / Behavior Changes

    • Increased drag sensitivity (node drag threshold raised to 5) and disabled click activation by distance (node click distance set to Infinity).

@changeset-bot
Copy link

changeset-bot bot commented Oct 20, 2025

⚠️ No Changeset found

Latest commit: b1ec8ed

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link

coderabbitai bot commented Oct 20, 2025

Walkthrough

Updated XYDrag to track initial mouse position and a preventClick flag, disable click-distance initiation by setting clickDistance to Infinity, intercept capture-phase clicks to suppress incidental clicks after drag, and remove the handler on destroy. Also adjusted example node drag sensitivity and nodeClickDistance. (38 words)

Changes

Cohort / File(s) Change Summary
Drag click suppression & state management
packages/system/src/xydrag/XYDrag.ts
Added initialMousePosition and preventClick internal state; set clickDistance to Infinity; record initialMousePosition on drag start; update preventClick when nodes move or movement exceeds threshold; added capture-phase click handler to suppress post-drag clicks; remove handler in destroy.
Example configuration update
examples/react/src/examples/Basic/index.tsx
Increased nodeDragThreshold from 0 to 5; added nodeClickDistance: Infinity to disable click-distance based node activation.

Sequence Diagram

sequenceDiagram
    participant User
    participant XYDrag
    participant DOM

    rect rgb(240,248,255)
    Note over User,XYDrag: Drag start
    User->>XYDrag: mousedown
    XYDrag->>XYDrag: record initialMousePosition\npreventClick = false
    end

    rect rgb(255,250,235)
    Note over User,XYDrag: Dragging
    User->>XYDrag: mousemove
    XYDrag->>XYDrag: compute distance from initialMousePosition\nif nodes moved or distance > threshold → preventClick = true
    end

    rect rgb(240,255,240)
    Note over User,DOM: Drag end & click interception
    User->>XYDrag: mouseup
    XYDrag->>DOM: native click may be emitted
    DOM->>DOM: capture-click handler checks preventClick / distance
    alt preventClick == true or distance exceeded
        DOM->>DOM: stopImmediatePropagation / preventDefault (suppress click)
    else
        DOM->>User: allow click
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰
I hopped, I traced the silent start,
Marked the place where motions part,
When nodes wandered off the track,
I caught the click and pushed it back.
Soft thumps — no echoes on the chart.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "fix nodeClickDistance" directly refers to the core change in this pull request. The PR implements fixes to the nodeClickDistance behavior to prevent unintended position change events during clicks when snapToGrid is enabled, which is exactly what the title indicates. The title is concise, specific, and clearly summarizes the primary objective from the developer's perspective without unnecessary noise.
Linked Issues Check ✅ Passed The code changes directly address the requirements from linked issue #5204. The modifications to XYDrag.ts implement click suppression logic when node positions have changed by adding preventClick state tracking and a capture-phase click handler that uses initialMousePosition for distance calculations. The example configuration update setting nodeClickDistance to Infinity demonstrates the intended behavior where clicks are prevented after dragging, preventing unintended position change events that were causing the multiselect glitch when snapToGrid is enabled. These changes align with the objective to prevent unintended position events on click and stabilize multiselect selection behavior.
Out of Scope Changes Check ✅ Passed All changes in this pull request are directly related to the stated objectives of fixing nodeClickDistance behavior and preventing unintended position change events. The XYDrag.ts modifications implement the core click suppression and preventClick logic necessary to fix the snapToGrid selection glitch. The Basic/index.tsx example updates (nodeDragThreshold change and nodeClickDistance configuration) demonstrate the intended fix and are clearly within scope as they showcase the corrected behavior. No extraneous or unrelated changes are present.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/node-click-distance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@peterkogo
Copy link
Member Author

Suggestion about how to continue with this:

  • We release this without deprecating anything. nodeClickDistance might still be useful if you have a very wide grid of [15,15] but still want to prevent clicks if the mouse has moved more than 5px.
  • In the next major we make the default value of nodeClickDistance Infinity

I am very sure no one is rocking a config right now where nodeClickDistance > nodeDragThreshold. Even if they are, it is currently broken anyway so this seems like a plausible way forward.

@peterkogo peterkogo marked this pull request as ready for review October 22, 2025 09:18
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/system/src/xydrag/XYDrag.ts (2)

341-351: Use initialMousePosition for threshold calc (clearer, avoids accidental drift).

You compute distance from mousePosition, which later mutates. Using initialMousePosition is simpler and intention-revealing. Also prefer Math.hypot.

-          const currentMousePosition = getEventPosition(event.sourceEvent, containerBounds!);
-          const x = currentMousePosition.x - mousePosition.x;
-          const y = currentMousePosition.y - mousePosition.y;
-          const distance = Math.sqrt(x * x + y * y);
+          const currentMousePosition = getEventPosition(event.sourceEvent, containerBounds!);
+          const dx = currentMousePosition.x - initialMousePosition.x;
+          const dy = currentMousePosition.y - initialMousePosition.y;
+          const distance = Math.hypot(dx, dy);

102-104: State additions are fine; just ensure they’re updated for non-draggable interactions.

initialMousePosition and preventClick are appropriate. The issues arise from when they’re not updated (non-draggable clicks). Addressed in the click handler refactor comment.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8e7c539 and 29e8187.

📒 Files selected for processing (1)
  • packages/system/src/xydrag/XYDrag.ts (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/system/src/xydrag/XYDrag.ts (2)
packages/system/src/types/general.ts (1)
  • UseDragEvent (267-267)
packages/system/src/utils/dom.ts (1)
  • getEventPosition (52-61)
🔇 Additional comments (2)
packages/system/src/xydrag/XYDrag.ts (2)

299-301: clickDistance(Infinity) rationale looks good; confirm intent vs d3 semantics.

d3’s clickDistance controls suppression of the click after mouseup, not drag initiation. Setting it to Infinity disables d3’s built-in click suppression so your custom capture-phase logic governs clicks. Please confirm with tests across mouse/touch.

According to d3-drag docs, drag.clickDistance sets the maximum pointer movement that will still allow the subsequent click; measured in client coordinates; default 0. Setting Infinity prevents d3 from suppressing any click, leaving suppression to your code. (d3js.org)


303-308: Start-state resets and initial position capture — LGTM.

Resetting flags and capturing client-coord initialMousePosition at gesture start is correct and consistent with the new thresholding.

Also applies to: 317-318

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
packages/system/src/xydrag/XYDrag.ts (1)

402-418: Critical: stale initialMousePosition on non‑draggable clicks can suppress unrelated clicks; add mousedown/touchstart capture and bounds fallback.

If filter=false (e.g., clicking an input inside a node), “start” won’t run, leaving initialMousePosition stale. The capture-phase click handler then computes a large distance and wrongly blocks the click. Capture initial position on mousedown/touchstart regardless of drag filter, and fall back to fresh bounds in the click handler.

-    d3Selection.on(
-      'click.xydrag',
-      (event: MouseEvent) => {
-        const currentMousePosition = getEventPosition(event, containerBounds!);
-        const x = currentMousePosition.x - initialMousePosition.x;
-        const y = currentMousePosition.y - initialMousePosition.y;
-        const distance = Math.sqrt(x * x + y * y);
-
-        if (preventClick || distance > nodeClickDistance) {
-          preventClick = false;
-          event.preventDefault();
-          event.stopPropagation();
-        }
-      },
-      { capture: true }
-    );
+    d3Selection
+      .on('mousedown.xydrag', (downEvent: MouseEvent) => {
+        const bounds = (downEvent.currentTarget as Element).getBoundingClientRect();
+        containerBounds = bounds;
+        initialMousePosition = getEventPosition(downEvent, bounds);
+      }, { capture: true })
+      .on('touchstart.xydrag', (downEvent: TouchEvent) => {
+        const bounds = (downEvent.currentTarget as Element).getBoundingClientRect();
+        containerBounds = bounds;
+        initialMousePosition = getEventPosition(downEvent, bounds);
+      }, { capture: true })
+      .on('click.xydrag', (event: MouseEvent) => {
+        const bounds = containerBounds ?? (event.currentTarget as Element).getBoundingClientRect();
+        const { x, y } = getEventPosition(event, bounds);
+        const dx = x - initialMousePosition.x;
+        const dy = y - initialMousePosition.y;
+        const distance = Math.hypot(dx, dy);
+        if (preventClick || distance > nodeClickDistance) {
+          event.preventDefault();
+          event.stopPropagation();
+          // single-shot suppression
+          if (preventClick) preventClick = false;
+        }
+      }, { capture: true });

Also, when rebinding to a new domNode inside update(), unbind previous handlers to avoid leaks/duplicates:

   }: DragUpdateParams) {
-    d3Selection = select(domNode);
+    if (d3Selection) {
+      d3Selection.on('.drag', null).on('.xydrag', null);
+    }
+    d3Selection = select(domNode);
🧹 Nitpick comments (2)
packages/system/src/xydrag/XYDrag.ts (2)

341-347: Minor: use Math.hypot for clarity.

Replace manual sqrt with Math.hypot.

-          const x = currentMousePosition.x - initialMousePosition.x;
-          const y = currentMousePosition.y - initialMousePosition.y;
-          const distance = Math.sqrt(x * x + y * y);
+          const dx = currentMousePosition.x - initialMousePosition.x;
+          const dy = currentMousePosition.y - initialMousePosition.y;
+          const distance = Math.hypot(dx, dy);

421-424: Namespace cleanup for reliability.

Remove all our namespaced handlers in destroy, not only click.

   function destroy() {
     d3Selection?.on('.drag', null);
-    d3Selection?.on('click.xydrag', null);
+    d3Selection?.on('.xydrag', null);
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 29e8187 and e00472d.

📒 Files selected for processing (1)
  • packages/system/src/xydrag/XYDrag.ts (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/system/src/xydrag/XYDrag.ts (2)
packages/system/src/types/general.ts (1)
  • UseDragEvent (267-267)
packages/system/src/utils/dom.ts (1)
  • getEventPosition (52-61)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Playwright Tests
🔇 Additional comments (4)
packages/system/src/xydrag/XYDrag.ts (4)

102-104: State for click suppression looks good.

Initialization of initialMousePosition and preventClick is clear and appropriate.


298-300: Relying on custom click suppression via clickDistance(Infinity).

This is reasonable; please sanity-check on iOS Safari and Windows Chrome to ensure no regressions in native click behavior after drags.


303-308: Correct resets on drag start.

Resetting dragStarted/autoPanStarted/preventClick and capturing the initial position is correct for consistent thresholds.

Also applies to: 317-318


369-373: Single‑shot click suppression on drag end: LGTM.

Setting preventClick when positions changed pairs well with resetting it in the click handler.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/system/src/xydrag/XYDrag.ts (1)

114-121: Avoid handler leaks on update(): clean up previous selection before rebinding.

Without detaching old listeners, repeated update() calls with a new domNode will leave orphaned handlers on the previous element.

Apply:

   function update({
@@
-  }: DragUpdateParams) {
-    d3Selection = select(domNode);
+  }: DragUpdateParams) {
+    // detach any prior handlers before rebinding to a potentially new domNode
+    if (d3Selection) {
+      d3Selection.on('.drag', null).on('.xydrag', null);
+    }
+    d3Selection = select(domNode);
♻️ Duplicate comments (1)
packages/system/src/xydrag/XYDrag.ts (1)

403-418: Critical: stale initialMousePosition on non‑draggable clicks; add mousedown/touchstart capture + bounds fallback.

click.xydrag relies on initialMousePosition set only in drag “start”. If a click occurs on a target that fails the drag filter, initialMousePosition is stale and legitimate clicks get suppressed; also no bounds fallback. Capture the initial position on every pointer down and compute a fresh bounds fallback in the click handler.

Apply:

-    d3Selection.on(
-      'click.xydrag',
-      (event: MouseEvent) => {
-        const currentMousePosition = getEventPosition(event, containerBounds!);
-        const x = currentMousePosition.x - initialMousePosition.x;
-        const y = currentMousePosition.y - initialMousePosition.y;
-        const distance = Math.sqrt(x * x + y * y);
-
-        if (preventClick || distance > nodeClickDistance) {
-          preventClick = false;
-          event.preventDefault();
-          event.stopPropagation();
-        }
-      },
-      { capture: true }
-    );
+    d3Selection
+      .on('mousedown.xydrag', (downEvent: MouseEvent) => {
+        const bounds = (downEvent.currentTarget as Element).getBoundingClientRect();
+        containerBounds = bounds;
+        initialMousePosition = getEventPosition(downEvent, bounds);
+      }, { capture: true })
+      .on('touchstart.xydrag', (downEvent: TouchEvent) => {
+        const bounds = (downEvent.currentTarget as Element).getBoundingClientRect();
+        containerBounds = bounds;
+        initialMousePosition = getEventPosition(downEvent, bounds);
+      }, { capture: true })
+      .on('click.xydrag', (event: MouseEvent) => {
+        const bounds = containerBounds ?? (event.currentTarget as Element).getBoundingClientRect();
+        const { x, y } = getEventPosition(event, bounds);
+        const dx = x - initialMousePosition.x;
+        const dy = y - initialMousePosition.y;
+        const distance = Math.hypot(dx, dy);
+
+        if (preventClick || distance > nodeClickDistance) {
+          preventClick = false; // single-shot suppression after drag
+          event.preventDefault();
+          event.stopPropagation();
+        }
+      }, { capture: true });
🧹 Nitpick comments (1)
packages/system/src/xydrag/XYDrag.ts (1)

341-347: Tiny tidy: use Math.hypot and clearer names.

No behavior change; improves readability.

-          const currentMousePosition = getEventPosition(event.sourceEvent, containerBounds!);
-          const x = currentMousePosition.x - initialMousePosition.x;
-          const y = currentMousePosition.y - initialMousePosition.y;
-          const distance = Math.sqrt(x * x + y * y);
+          const pos = getEventPosition(event.sourceEvent, containerBounds!);
+          const dx = pos.x - initialMousePosition.x;
+          const dy = pos.y - initialMousePosition.y;
+          const distance = Math.hypot(dx, dy);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e00472d and 685bdfa.

📒 Files selected for processing (1)
  • packages/system/src/xydrag/XYDrag.ts (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/system/src/xydrag/XYDrag.ts (2)
packages/system/src/types/general.ts (1)
  • UseDragEvent (267-267)
packages/system/src/utils/dom.ts (1)
  • getEventPosition (52-61)
🔇 Additional comments (2)
packages/system/src/xydrag/XYDrag.ts (2)

298-300: Setting d3 clickDistance to Infinity — good call.

Disables d3’s internal click initiation so your custom suppression governs behavior.


369-373: Single‑shot click suppression after move — looks correct.

preventClick only set when positions changed; click handler resets it after suppression.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
examples/react/src/examples/Basic/index.tsx (1)

165-166: Consider demonstrating snapToGrid to showcase the fix.

The configuration values are correct and align with the PR objectives. However, since this PR primarily fixes issue #5204 (snapToGrid selection glitch), consider adding snapToGrid={true} to this example to demonstrate the fix in action. Additionally, brief comments explaining why nodeClickDistance={Infinity} is recommended would help users understand the new behavior.

Example addition:

         nodeDragThreshold={5}
+        snapToGrid={true}
+        snapGrid={[15, 15]}
         nodeClickDistance={Infinity}

Or add a comment explaining the configuration:

        nodeDragThreshold={5}
        // Infinity prevents click events after any position change, recommended for snapGrid
        nodeClickDistance={Infinity}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 685bdfa and b1ec8ed.

📒 Files selected for processing (2)
  • examples/react/src/examples/Basic/index.tsx (1 hunks)
  • packages/system/src/xydrag/XYDrag.ts (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/system/src/xydrag/XYDrag.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Playwright Tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

snapToGrid = true causes selection glitch during multiselect

2 participants