diff --git a/src/List.tsx b/src/List.tsx index a7b438c..d622e67 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -14,6 +14,7 @@ import { useGetSize } from './hooks/useGetSize'; import useHeights from './hooks/useHeights'; import useMobileTouchMove from './hooks/useMobileTouchMove'; import useOriginScroll from './hooks/useOriginScroll'; +import useDragEdgeScroll from './hooks/useDragEdgeScroll'; import useScrollDrag from './hooks/useScrollDrag'; import type { ScrollOffset, ScrollOffsetInfo, ScrollPos, ScrollTarget } from './hooks/useScrollTo'; import useScrollTo from './hooks/useScrollTo'; @@ -470,6 +471,14 @@ export function RawList(props: ListProps, ref: React.Ref) { syncScrollTop((top) => top + offset); }); + // Native HTML5 drag (e.g. a draggable Tree node) toward the edge: the + // `mousemove` path above is not fired during a native drag, and the native + // drag-to-edge autoscroll is disabled by `overflow: hidden`, so drive it from + // the drag events here instead. + useDragEdgeScroll(inVirtual, componentRef, height, itemHeight, (offset) => { + syncScrollTop((top) => top + offset); + }); + useLayoutEffect(() => { // Firefox only function onMozMousePixelScroll(e: WheelEvent) { diff --git a/src/hooks/useDragEdgeScroll.ts b/src/hooks/useDragEdgeScroll.ts new file mode 100644 index 0000000..6a71827 --- /dev/null +++ b/src/hooks/useDragEdgeScroll.ts @@ -0,0 +1,119 @@ +import { raf } from '@rc-component/util'; +import * as React from 'react'; +import { smoothScrollOffset } from './useScrollDrag'; + +/** + * The mouse-drag path in `useScrollDrag` runs on `mousemove`, which the browser + * does not fire during a native HTML5 drag — and it deliberately skips + * draggable targets so it never fights the native drag. So when a consumer + * (e.g. a draggable Tree) drags a node toward the edge of a virtual list, the + * container is `overflow: hidden`, the browser's native drag-to-edge autoscroll + * is disabled, and nothing scrolls. + * + * Drive the edge scrolling from the native drag events instead. `dragover` only + * fires while a drag is in progress, so no extra "is dragging" flag is needed: + * the listeners can stay mounted and simply do nothing until a drag happens. + */ +export default function useDragEdgeScroll( + inVirtual: boolean, + componentRef: React.RefObject, + height: number, + itemHeight: number, + onScrollOffset: (offset: number) => void, +) { + const onScrollOffsetRef = React.useRef(onScrollOffset); + onScrollOffsetRef.current = onScrollOffset; + + React.useEffect(() => { + const ele = componentRef.current; + if (!inVirtual || !ele || !height) { + return; + } + + // `height / 4` caps each band so the top and bottom never overlap on short + // containers (an idle zone always remains in the middle); `itemHeight * 1.2` + // keeps a roughly one-row band otherwise. + const edgeThreshold = Math.min(itemHeight * 1.2, height / 4); + + let rafId: number | null = null; + let offset = 0; + + const stopScroll = () => { + if (rafId !== null) { + raf.cancel(rafId); + rafId = null; + } + offset = 0; + }; + + const scrollFrame = () => { + onScrollOffsetRef.current(offset); + rafId = raf(scrollFrame); + }; + + const continueScroll = () => { + // `0` happens when the pointer sits exactly on the band boundary; spinning + // the loop for a no-op would fire a scroll callback on every frame. + if (offset === 0) { + stopScroll(); + return; + } + if (rafId === null) { + rafId = raf(scrollFrame); + } + }; + + const onDragOver = (e: DragEvent) => { + // Skip if a nested virtual List already handled this event, the same way + // `useScrollDrag` does on `mousedown`: a nested List's `dragover` bubbles + // to both holders and only the innermost one should scroll. + const event = e as DragEvent & { _virtualHandled?: boolean }; + if (event._virtualHandled) { + return; + } + // `dragover` keeps firing while the drag lasts, so mark each event: the + // innermost holder sees it first and the outer ones bail out above. + event._virtualHandled = true; + + const { top, bottom } = ele.getBoundingClientRect(); + const { clientY } = e; + + if (clientY <= top + edgeThreshold) { + offset = -smoothScrollOffset(top + edgeThreshold - clientY); + continueScroll(); + } else if (clientY >= bottom - edgeThreshold) { + offset = smoothScrollOffset(clientY - (bottom - edgeThreshold)); + continueScroll(); + } else { + stopScroll(); + } + }; + + const onDragLeave = (e: DragEvent) => { + const related = e.relatedTarget as Node | null; + if (!related || !ele.contains(related)) { + stopScroll(); + } + }; + + const ownerDocument = ele.ownerDocument; + + // `dragover` / `dragleave` need the container rect, so listen on it. + // `drop` / `dragend` are listened on the document in the CAPTURE phase: a + // consumer's node may stop their propagation in the bubble phase (rc-tree's + // `TreeNode` does), so capture is the only reliable place to see the drag + // ending. + ele.addEventListener('dragover', onDragOver); + ele.addEventListener('dragleave', onDragLeave); + ownerDocument.addEventListener('drop', stopScroll, true); + ownerDocument.addEventListener('dragend', stopScroll, true); + + return () => { + stopScroll(); + ele.removeEventListener('dragover', onDragOver); + ele.removeEventListener('dragleave', onDragLeave); + ownerDocument.removeEventListener('drop', stopScroll, true); + ownerDocument.removeEventListener('dragend', stopScroll, true); + }; + }, [inVirtual, height, itemHeight]); +} diff --git a/src/hooks/useScrollDrag.ts b/src/hooks/useScrollDrag.ts index 226c555..7449d7c 100644 --- a/src/hooks/useScrollDrag.ts +++ b/src/hooks/useScrollDrag.ts @@ -1,7 +1,7 @@ import { raf } from '@rc-component/util'; import * as React from 'react'; -function smoothScrollOffset(offset: number) { +export function smoothScrollOffset(offset: number) { return Math.floor(offset ** 0.5); } diff --git a/tests/dragEdgeScroll.test.js b/tests/dragEdgeScroll.test.js new file mode 100644 index 0000000..41558e2 --- /dev/null +++ b/tests/dragEdgeScroll.test.js @@ -0,0 +1,281 @@ +import { act, createEvent, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import List from '../src'; +import { spyElementPrototypes } from './utils/domHook'; + +function genData(count) { + return new Array(count).fill(null).map((_, index) => ({ id: String(index) })); +} + +// The virtual list positions its content by writing `transform: translateY(...)` +// as a plain string, so this does not depend on a real layout engine and stays +// correct under jsdom. +function getScrollTop(container) { + const innerEle = container.querySelector('.rc-virtual-list-holder-inner'); + const { transform } = innerEle.style; + const m = transform && transform.match(/\d+/); + return m ? Number(m[0]) : 0; +} + +// jsdom's synthetic drag event drops unknown init props, so assign the pointer +// coordinate directly (same approach as the mouse-drag specs). +function fireDragOver(el, clientY) { + const event = createEvent.dragOver(el); + event.clientY = clientY; + fireEvent(el, event); +} + +function fireDragLeave(el, relatedTarget) { + const event = createEvent.dragLeave(el); + event.relatedTarget = relatedTarget; + fireEvent(el, event); +} + +describe('List.NativeDragEdgeScroll', () => { + let mockElement; + + beforeEach(() => { + jest.useFakeTimers(); + // Container rect: top=0, bottom=100, height=100. + // edgeThreshold = min(itemHeight * 1.2, height / 4) = min(24, 25) = 24. + // => top band [0, 24], bottom band [76, 100], idle zone (24, 76). + mockElement = spyElementPrototypes(HTMLElement, { + offsetHeight: { + get() { + const height = this.getAttribute('data-height'); + return Number(height || 20); + }, + }, + clientHeight: { + get: () => 100, + }, + getBoundingClientRect: () => ({ top: 0, bottom: 100, width: 100, height: 100 }), + offsetParent: { + get: () => document.body, + }, + }); + }); + + afterEach(() => { + mockElement.mockRestore(); + jest.useRealTimers(); + }); + + function renderList(props) { + return render( + + {({ id }) =>
  • {id}
  • } +
    , + ); + } + + function getInnerLi(container) { + return container.querySelector('.rc-virtual-list-holder-inner li'); + } + + it('scrolls down when a drag reaches the bottom edge', () => { + const { container } = renderList(); + expect(getScrollTop(container)).toEqual(0); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container)).toBeGreaterThan(0); + }); + + it('scrolls up when a drag reaches the top edge', () => { + const listRef = React.createRef(); + const { container } = renderList({ ref: listRef }); + act(() => { + listRef.current.scrollTo(400); + jest.advanceTimersByTime(100); + }); + const before = getScrollTop(container); + expect(before).toBeGreaterThan(0); + + fireDragOver(getInnerLi(container), 5); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container)).toBeLessThan(before); + }); + + it('does not scroll while the pointer stays in the idle middle zone', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 50); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(0); + }); + + it('does not scroll when the pointer sits exactly on the band boundary', () => { + const { container } = renderList(); + + // Prove the loop can run first: inside the bottom band. + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + expect(getScrollTop(container)).toBeGreaterThan(0); + const atBoundary = getScrollTop(container); + + // Exactly `bottom - edgeThreshold` (100 - 24 = 76): distance to the edge is + // 0, so the offset is 0 and the loop must stop instead of spinning. + fireDragOver(getInnerLi(container), 76); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(atBoundary); + }); + + it('reuses the running loop when a drag stays inside the edge band', () => { + const { container } = renderList(); + + // Baseline: one `dragover`, then measure how far the loop moves us. + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const perFrame = getScrollTop(container); + expect(perFrame).toBeGreaterThan(0); + + // Second `dragover`, still inside the band: the loop is already scheduled, so + // the next frame moves by the same amount. A second loop would double it. + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container) - perFrame).toBe(perFrame); + }); + + it('stops scrolling on drop', () => { + const { container } = renderList(); + const holder = container.querySelector('.rc-virtual-list-holder'); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + fireEvent.drop(holder); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(afterEdge); + }); + + it('stops scrolling on dragend even when released outside the container', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + // Release anywhere; the document-level capture listener must still stop it. + fireEvent.dragEnd(document.body); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(afterEdge); + }); + + it('stops scrolling when the drag really leaves the container', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + // `relatedTarget` outside the container => the pointer truly left. + fireDragLeave(getInnerLi(container), document.body); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(afterEdge); + }); + + it('keeps scrolling when the drag only moves between inner nodes', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + // `dragleave` also fires between inner nodes; a `relatedTarget` still inside + // the container must NOT be treated as leaving. + const holder = container.querySelector('.rc-virtual-list-holder'); + fireDragLeave(getInnerLi(container), holder); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container)).toBeGreaterThan(afterEdge); + }); + + it('only the innermost virtual list scrolls on a nested drag', () => { + // `examples/nest.tsx` renders a virtual List inside the item of another one, + // so a `dragover` on an inner item bubbles to both holders. + const { container } = render( + + {() => ( +
  • + + {({ id }) =>
  • {id}
  • } +
    + + )} + , + ); + + const holdersInners = () => container.querySelectorAll('.rc-virtual-list-holder-inner'); + const readTop = (ele) => { + const matched = ele.style.transform && ele.style.transform.match(/\d+/); + return matched ? Number(matched[0]) : 0; + }; + + // The mocked rect is 0..100 for every element, so `95` lands in both bottom + // bands: inner `min(10 * 1.2, 40 / 4)` = 10 -> [90, 100], outer + // `min(20 * 1.2, 100 / 4)` = 24 -> [76, 100]. Only the inner one may react. + fireDragOver(holdersInners()[1].querySelector('li'), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(readTop(holdersInners()[1])).toBeGreaterThan(0); + expect(readTop(holdersInners()[0])).toEqual(0); + }); + + it('does not attach the behavior for a non-virtual list', () => { + // `virtual={false}` keeps native drag-to-edge to the browser. + const { container } = renderList({ virtual: false }); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(0); + }); +});