clauderic / dnd-kit

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

React 18: Autoscroll is broken with multiple horizontal drop zones which have vertical scroll #1108

Open ThomDevine opened 1 year ago

ThomDevine commented 1 year ago

Hi,

I'm seeing this behavior only after updating to React 18. I was using 6.0.5, updating dnd-kit to 6.0.8 does not resolve this.

Setup

I have a horizontal, scrollable list of "useDroppable" columns. Each column contains a vertical list of "useDraggable" items. When the column contents' CSS overflow is set to auto, scroll, or hidden, this behavior occurs. If I change it to none, scrolling works as it did previously.

Behavior

When I try to drag an item from one column to another, as soon as the item is over a different drop zone, the main container (of columns) continues to scroll horizontally all the way to the end. I cannot drag in the other direction to scroll it back.

Changing DndContext's autoScroll with various options of order and acceleration has no effect.

Sandbox Eample

https://codesandbox.io/s/stoic-robinson-wgcrcx?file=/src/App.jsx There are no handlers attached to DndContext, no underlying data is changed, so this appears to be contained within autoScroll.

Try dragging from a middle column to see the "unable to reverse" behavior. Search for "changeme" in App.js to find the relevant style change in the Column component. In my app, this style is set by a CSS file instead of inline, and the behavior is the same.

Note: I'm not using Sortable here because the column contents have a strict order defined by the server. https://github.com/clauderic/dnd-kit/issues/1098 might be related, but that's for Sortable so I'm not sure.

ThomDevine commented 1 year ago

I have confirmed that by loading my app the old way, with render(app, mountPoint) from the "react-dom" package to get React 17 behavior, this bug goes away. This is not a valid workaround though, as this method is not supported in React 18.

csc-bo commented 1 year ago

I encountered the same issue. Does anyone have a solution for React 18?

alexdonets commented 1 year ago

Same here. @ThomDevine have you found a workaround yet?

ThomDevine commented 1 year ago

@alexdonets No, we are still stuck on React 17 because of this issue.

I poked around in the library code to try and figure it out, which is how I came across the order option for autoScroll; it's not found in the docs. I expected setting that to TreeOrder.ReversedTreeOrder to work, that seems like what it's designed for, but it didn't.

In our use case, some columns might have 3 items and others 300, so we need this functionality. I have no idea why React 18's way of rendering breaks this.

murrayee commented 1 year ago

has same issue

Innders commented 1 year ago

I've just encountered this bug as well and can't find anyway work around.

Innders commented 1 year ago

So this is my super hacky DIY fix.

1. First disable auto scroll

<DndContext autoScroll={false}>
   <ScrollableSection />
</DndContext>

2. Make sure your horizontal scrollable element is inside DndContext in another component like ScrollableSection.

3. Inside of ScrollableSection do your auto scrolling

const ScrollableSection = ({columns}) => {
  const { active } = useDndContext()
  const sectionRef = useRef(null)

  const [scrollDirection, setScrollDirection] = useState(null)

  // this scrolls the section based on the direction
  useEffect(() => {
    if (!scrollDirection) return

    const el = sectionRef.current
    if (!el) return

    const speed = 10

    const intervalId = setInterval(() => {
      el.scrollLeft += speed * scrollDirection
    }, 5)

    return () => {
      clearInterval(intervalId)
    }
  }, [scrollDirection, sectionRef.current])

  // if we are dragging, detect if we are near the edge of the section
  useEffect(() => {
    const handleMouseMove = (event) => {
      const el = sectionRef.current
      if (!active || !el) return
      const isOverflowing = el.scrollWidth > el.clientWidth
      if (!isOverflowing) return

      // get bounding box of the section
      const { left, right } = el.getBoundingClientRect()
      // xPos of the mouse
      const xPos = event.clientX
      const threshold = 200

      const newScrollDirection = xPos < left + threshold ? -1 : xPos > right - threshold ? 1 : null
      if (newScrollDirection !== scrollDirection) {
        setScrollDirection(newScrollDirection)
      }
    }
    if (active) {
      window.addEventListener('mousemove', handleMouseMove)
    } else {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
    return () => {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
  }, [active, sectionRef.current])

  return (
    <div
      style={{
        height: '100%',
        width: '100%',
        display: 'flex',
        overflowX: 'auto',
      }}
      ref={sectionRef}
    >
      {columns.map(({ id }) => {
        return (
          <Column
            key={id}
            id={id}
          />
        )
      })}
    </div>
  )
}

You might want to do some throttling on that mouse event listener to improve performance.

If anyone has any other way of cleaning this up please feel free to!

murrayee commented 1 year ago

So this is my super hacky DIY fix.

1. First disable auto scroll

<DndContext autoScroll={false}>
   <ScrollableSection />
</DndContext>

2. Make sure your horizontal scrollable element is inside DndContext in another component like ScrollableSection.

3. Inside of ScrollableSection do your auto scrolling

const ScrollableSection = ({columns}) => {
  const { active } = useDndContext()
  const sectionRef = useRef(null)

  const [scrollDirection, setScrollDirection] = useState(null)

  // this scrolls the section based on the direction
  useEffect(() => {
    if (!scrollDirection) return

    const el = sectionRef.current
    if (!el) return

    const speed = 10

    const intervalId = setInterval(() => {
      el.scrollLeft += speed * scrollDirection
    }, 5)

    return () => {
      clearInterval(intervalId)
    }
  }, [scrollDirection, sectionRef.current])

  // if we are dragging, detect if we are near the edge of the section
  useEffect(() => {
    const handleMouseMove = (event) => {
      const el = sectionRef.current
      if (!active || !el) return
      const isOverflowing = el.scrollWidth > el.clientWidth
      if (!isOverflowing) return

      // get bounding box of the section
      const { left, right } = el.getBoundingClientRect()
      // xPos of the mouse
      const xPos = event.clientX
      const threshold = 200

      const newScrollDirection = xPos < left + threshold ? -1 : xPos > right - threshold ? 1 : null
      if (newScrollDirection !== scrollDirection) {
        setScrollDirection(newScrollDirection)
      }
    }
    if (active) {
      window.addEventListener('mousemove', handleMouseMove)
    } else {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
    return () => {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
  }, [active, sectionRef.current])

  return (
    <div
      style={{
        height: '100%',
        width: '100%',
        display: 'flex',
        overflowX: 'auto',
      }}
      ref={sectionRef}
    >
      {columns.map(({ id }) => {
        return (
          <Column
            key={id}
            id={id}
          />
        )
      })}
    </div>
  )
}

You might want to do some throttling on that mouse event listener to improve performance.

If anyone has any other way of cleaning this up please feel free to!

Thanks to @Innders for the solution, I also wanted to DIY my own scroll, but my business is that there are multiple boards, each board requires virtual scrolling vertically, each board has multiple columns, and each column also requires virtual scrolling, It's so hard

gaburielcasado commented 8 months ago

Well I don't know if this "less hacky" or even better performance-wise, but I had to disable vertical autoscrolling, while allowing horizontal autoscrolling to proceed. To disable vertical auto scrolling:

(<DndContext
        autoScroll={{
            threshold: {
                x: 0.2,
                y: 0
            }
        }}
        ...otherProps
>
  ...
  <div>
    <DragOverlay
          style={{
              // important for vertical custom scrolling
              pointerEvents: 'none'
          }}
      >
    </DragOverlay>
  </div>
</DndContext>)

Then to add manual vertical scrolling to a container:

const [isNearTop, setIsNearTop] = useState(false)
const [isNearBottom, setIsNearBottom] = useState(false)
const isDragging = useRef(false)

useDndMonitor({
    onDragStart(e) {
        isDragging.current = true
    },
    onDragEnd(e) {
        isDragging.current = false
    }
})

function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
        if (containerRef.current == null) {
            return
        }
        const { top, bottom } = containerRef.current.getBoundingClientRect()
        const nearTop = e.clientY - top < 50
        const nearBottom = bottom - e.clientY < 50

        setIsNearTop(nearTop)
        setIsNearBottom(nearBottom)
    }

    useEffect(() => {
        if (!isDragging.current) {
            return
        }
        const container = containerRef.current
        if (!container) return

        const scrollAmount = 20

        const scrollContainer = () => {
          if (isNearTop) {
            container.scrollTop -= scrollAmount
          } else if (isNearBottom) {
            container.scrollTop += scrollAmount
          }
        }

        const intervalId = setInterval(scrollContainer, 20)

        return () => clearInterval(intervalId)
      }, [isNearTop, isNearBottom])

<div
  onMouseMove={handleMouseMove}
  style={{
    ...overflowHeightAndOtherPropsToEnableScroll
  }}
>

</div>
hectormartinez-facephi commented 7 months ago

In order to stop scrolling when the mouse is located in the center. I have added these lines in the useEffect that sets the scroll direction:

else {
    setScrollDirection(null)
}

1. First disable auto scroll

 <DndContext autoScroll={false}>
    <ScrollableSection />
 </DndContext>

2. Make sure your horizontal scrollable element is inside DndContext in another component like ScrollableSection.

3. Inside of ScrollableSection do your auto scrolling

 const ScrollableSection = ({columns}) => {
   const { active } = useDndContext()
   const sectionRef = useRef(null)

   const [scrollDirection, setScrollDirection] = useState(null)

   // this scrolls the section based on the direction
   useEffect(() => {
     if (!scrollDirection) return

     const el = sectionRef.current
     if (!el) return

     const speed = 10

     const intervalId = setInterval(() => {
       el.scrollLeft += speed * scrollDirection
     }, 5)

     return () => {
       clearInterval(intervalId)
     }
   }, [scrollDirection, sectionRef.current])

   // if we are dragging, detect if we are near the edge of the section
   useEffect(() => {
     const handleMouseMove = (event) => {
       const el = sectionRef.current
       if (!active || !el) return
       const isOverflowing = el.scrollWidth > el.clientWidth
       if (!isOverflowing) return

       // get bounding box of the section
       const { left, right } = el.getBoundingClientRect()
       // xPos of the mouse
       const xPos = event.clientX
       const threshold = 200

       const newScrollDirection = xPos < left + threshold ? -1 : xPos > right - threshold ? 1 : null
       if (newScrollDirection !== scrollDirection) {
         setScrollDirection(newScrollDirection)
       //Stops scrolling
       } else { 
        setScrollDirection(null)
       }
     }
     if (active) {
       window.addEventListener('mousemove', handleMouseMove)
     } else {
       window.removeEventListener('mousemove', handleMouseMove)
       setScrollDirection(null)
     }
     return () => {
       window.removeEventListener('mousemove', handleMouseMove)
       setScrollDirection(null)
     }
   }, [active, sectionRef.current])

   return (
     <div
       style={{
         height: '100%',
         width: '100%',
         display: 'flex',
         overflowX: 'auto',
       }}
       ref={sectionRef}
     >
       {columns.map(({ id }) => {
         return (
           <Column
             key={id}
             id={id}
           />
         )
       })}
     </div>
   )
 }