clauderic / dnd-kit

The modern, lightweight, performant, accessible and extensible drag & drop toolkit for React.
http://dndkit.com
MIT License
12.64k stars 630 forks source link

rectIntersection detects collision when the drop area is hidden behind scroll #1198

Open mihkeleidast opened 1 year ago

mihkeleidast commented 1 year ago

Reproduction: https://codesandbox.io/s/react-dnd-grid-forked-s9h7lm?file=/src/App.js

Description

In the repro, the drop areas are wide inside a horizontally scrollable container. The draggable items are in a sidebar, I guess technically they are "on top" of the drop areas. When I pick up a draggable item in the sidebar, it immediately reports a collision with the drop area, even though I haven't dragged the item to the scrollable container yet. So technically there is a collision, but visually there is not.

image

Expected behavior

Collision is not detected before dragging the item over the scrollable area. If the drop area is hidden behind scroll, it should not detect a collision.

Workaround

I worked around this by adding an intersection check with the scrollable container before the main rectIntersection collision check. While this works, it still feels like unexpected behavior and a bug.

psychedelicious commented 1 year ago

@mihkeleidast I've run into the same issue and was happy to see it's not a problem with my implementation.

I think my idea for a workaround is the same as yours:

Is that accurate? If not, would you mind elaborating?

Thanks!

psychedelicious commented 1 year ago

Here's what I came up with. It only works for the pointerWithin collision detection strategy.

import { CollisionDetection, pointerWithin } from '@dnd-kit/core';

/**
 * Filters out droppable elements that are overflowed, then applies the pointerWithin collision detection.
 *
 * Fixes collision detection firing on droppables that are not visible, having been scrolled out of view.
 *
 * See https://github.com/clauderic/dnd-kit/issues/1198
 */
export const customPointerWithin: CollisionDetection = (arg) => {
  if (!arg.pointerCoordinates) {
    // sanity check
    return [];
  }

  // Get all elements at the pointer coordinates. This excludes elements which are overflowed,
  // so it won't include the droppable elements that are scrolled out of view.
  const targetElements = document.elementsFromPoint(
    arg.pointerCoordinates.x,
    arg.pointerCoordinates.y
  );

  const filteredDroppableContainers = arg.droppableContainers.filter(
    (container) => {
      if (!container.node.current) {
        return false;
      }
      // Only include droppable elements that are in the list of elements at the pointer coordinates.
      return targetElements.includes(container.node.current);
    }
  );

  // Run the provided collision detection with the filtered droppable elements.
  return pointerWithin({
    ...arg,
    droppableContainers: filteredDroppableContainers,
  });
};
mihkeleidast commented 12 months ago

Here's my workaround, I created a hook that returns a custom rectIntersection function - this was the easiest solution for me since I had the scroll container ref already:

import { CollisionDetection, ClientRect, rectIntersection } from '@dnd-kit/core';
import React from 'react';

/**
 * Returns the intersecting rectangle area between two rectangles
 */
function getIntersectionRatio(entry: ClientRect, target: ClientRect): number {
  const top = Math.max(target.top, entry.top);
  const left = Math.max(target.left, entry.left);
  const right = Math.min(target.left + target.width, entry.left + entry.width);
  const bottom = Math.min(target.top + target.height, entry.top + entry.height);
  const width = right - left;
  const height = bottom - top;

  if (left < right && top < bottom) {
    const targetArea = target.width * target.height;
    const entryArea = entry.width * entry.height;
    const intersectionArea = width * height;
    const intersectionRatio = intersectionArea / (targetArea + entryArea - intersectionArea);

    return Number(intersectionRatio.toFixed(4));
  }

  // Rectangles do not overlap, or overlap has an area of zero (edge/corner overlap)
  return 0;
}

export const useRectIntersection = (scrollContainerRef: React.RefObject<HTMLElement | null>): CollisionDetection => {
  return ({ active, collisionRect, droppableRects, droppableContainers, pointerCoordinates }) => {
    /**
     * Check for collision with the scroll container before the droppabble containers.
     * The droppable containers detect collision even if the droppabble is hidden behind scroll.
     * https://github.com/clauderic/dnd-kit/issues/1198
     */
    const rect = scrollContainerRef.current?.getBoundingClientRect();

    if (rect) {
      const intersectionRatio = getIntersectionRatio(rect, collisionRect);

      if (intersectionRatio === 0) {
        return [];
      }
    }

    return rectIntersection({ active, collisionRect, droppableRects, droppableContainers, pointerCoordinates });
  };
};

The real solution should probably be more dynamic and also factor in multiple scrollable parents, etc.

psychedelicious commented 12 months ago

Thanks for sharing your workaround @mihkeleidast

GerroDen commented 10 months ago

Thanks for sharing. I have the same issue and the same setup to move cards from a sidebar to a 2D canvas which is scrollable and is hidden behind the sidebar. It's not even managed by z-index but by a grid layout. But while dragging from or inside the sidebar, I end up in an endless loop of detecting the sidebar and the droparea behind the sidebar, which is hidden by overflow rules. I understand, that the collision is based on BoundingClientRect, but this workaround might also help me.

Would be nice to see that as a default method of detection. It's quite advanced.

GerroDen commented 10 months ago

I've seen in the docs, that pointer within detection is prone to detect underlying dropzones. Apparently also from overflowing containers. The closest corner algorithm is not as forgiving in UX but at least does not crash the application due to endless loop of detected dropzones. So my workaround is to use closest corders instead of pointer within detection.