diff --git a/README.md b/README.md index ea167c4..096ff0c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,34 @@ export default class App extends Component { } ``` -Note: You should replace the original `div` you would like to make scrollable with the `ScrollingComponent`. +Note: You should replace the original `div` you would like to make scrollable with the `ScrollingComponent`. + +### useDndScrolling +```js +import React, { Component, useRef } from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { useDndScrolling } from 'react-dnd-scrolling'; +import DragItem from './DragItem'; +import './App.css'; + +const ITEMS = [1,2,3,4,5,6,7,8,9,10]; + +export default function App() { + const ref = useRef(); + useDndScrolling(ref); + + return ( + +
+ {ITEMS.map(n => ( + + ))} +
+
+ ); +} +``` ### Easing Example @@ -92,7 +119,7 @@ export default App(props) { ); } ``` -Note: You should replace the original `div` you would like to make scrollable with the `ScrollingComponent`. +Note: You should replace the original `div` you would like to make scrollable with the `ScrollingComponent`. ### Virtualized Example diff --git a/package-lock.json b/package-lock.json index 25de415..ec58da7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.2.4", "license": "MIT", "dependencies": { + "defaults": "^1.0.4", "hoist-non-react-statics": "3.x", "lodash.throttle": "^4.1.1", "prop-types": "15.x", @@ -2585,6 +2586,14 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -2768,6 +2777,17 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", @@ -7619,6 +7639,11 @@ "wrap-ansi": "^7.0.0" } }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" + }, "clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -7765,6 +7790,14 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "requires": { + "clone": "^1.0.2" + } + }, "define-properties": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", diff --git a/package.json b/package.json index 5368d4f..2f2b33d 100644 --- a/package.json +++ b/package.json @@ -43,19 +43,20 @@ ], "license": "MIT", "dependencies": { + "defaults": "^1.0.4", "hoist-non-react-statics": "3.x", "lodash.throttle": "^4.1.1", "prop-types": "15.x", "raf": "^3.4.1" }, "devDependencies": { - "@node-loader/babel": "^2.0.1", "@babel/cli": "^7.17.6", "@babel/core": "^7.17.8", "@babel/eslint-parser": "^7.17.0", "@babel/preset-env": "^7.16.11", "@babel/preset-react": "^7.16.7", "@babel/register": "^7.17.7", + "@node-loader/babel": "^2.0.1", "chai": "^4.3.6", "eslint": "^8.12.0", "eslint-config-airbnb": "^19.0.4", diff --git a/src/index.js b/src/index.js index bffe4dc..d8fe51a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ -import React, { Component } from 'react'; +import React, { useEffect, useRef, useCallback, useContext } from 'react'; import PropTypes from 'prop-types'; +import defaults from 'defaults'; import { DndContext } from 'react-dnd'; -import { findDOMNode } from 'react-dom'; import throttle from 'lodash.throttle'; import raf from 'raf'; import hoist from 'hoist-non-react-statics'; @@ -56,194 +56,211 @@ export const defaultHorizontalStrength = createHorizontalStrength(DEFAULT_BUFFER export const defaultVerticalStrength = createVerticalStrength(DEFAULT_BUFFER); -export default function createScrollingComponent(WrappedComponent) { - class ScrollingComponent extends Component { - static displayName = `Scrolling(${getDisplayName(WrappedComponent)})`; - - static propTypes = { - onScrollChange: PropTypes.func, - verticalStrength: PropTypes.func, - horizontalStrength: PropTypes.func, - strengthMultiplier: PropTypes.number - }; - - static defaultProps = { - onScrollChange: noop, - verticalStrength: defaultVerticalStrength, - horizontalStrength: defaultHorizontalStrength, - strengthMultiplier: 30 - }; - - static contextType = DndContext; - - constructor(props, ctx) { - super(props, ctx); - - this.scaleX = 0; - this.scaleY = 0; - this.frame = null; - - this.attached = false; - this.dragging = false; - } - - componentDidMount() { - this.container = findDOMNode(this.wrappedInstance); - this.container.addEventListener('dragover', this.handleEvent); - // touchmove events don't seem to work across siblings, so we unfortunately - // have to attach the listeners to the body - window.document.body.addEventListener('touchmove', this.handleEvent); +const defaultProps = { + onScrollChange: noop, + verticalStrength: defaultVerticalStrength, + horizontalStrength: defaultHorizontalStrength, + strengthMultiplier: 30, + dragDropManager: null +}; + +export function useDndScrolling(componentRef, passedOptions) { + const props = defaults(passedOptions, defaultProps); + const scaleX = useRef(0); + const scaleY = useRef(0); + const frame = useRef(0); + const attached = useRef(false); + const dragging = useRef(false); + + const { dragDropManager: dragDropManagerCtx } = useContext(DndContext); + const dragDropManager = props.dragDropManager ?? dragDropManagerCtx; + + if (!dragDropManager) { + throw new Error( + 'Unable to get dragDropManager from context. Provide DnDContext or pass dragDropManager via options to useDndScrolling.' + ); + } - this.clearMonitorSubscription = this.context.dragDropManager - .getMonitor() - .subscribeToStateChange(() => this.handleMonitorChange()); - } + const monitor = React.useMemo(() => dragDropManager.getMonitor(), [dragDropManager]); - componentWillUnmount() { - this.container.removeEventListener('dragover', this.handleEvent); - window.document.body.removeEventListener('touchmove', this.handleEvent); - this.clearMonitorSubscription(); - this.stopScrolling(); - } + const startScrolling = React.useCallback(() => { + let i = 0; + const tick = () => { + const { strengthMultiplier, onScrollChange } = props; - handleEvent = evt => { - if (this.dragging && !this.attached) { - this.attach(); - this.updateScrolling(evt); + // stop scrolling if there's nothing to do + if (strengthMultiplier === 0 || scaleX.current + scaleY.current === 0) { + // eslint-disable-next-line no-use-before-define + stopScrolling(); + return; } - }; - - handleMonitorChange() { - const isDragging = this.context.dragDropManager.getMonitor().isDragging(); - if (!this.dragging && isDragging) { - this.dragging = true; - } else if (this.dragging && !isDragging) { - this.dragging = false; - this.stopScrolling(); + // there's a bug in safari where it seems like we can't get + // mousemove events from a container that also emits a scroll + // event that same frame. So we double the strengthMultiplier and only adjust + // the scroll position at 30fps + if (i++ % 2) { + const { + scrollLeft, + scrollTop, + scrollWidth, + scrollHeight, + clientWidth, + clientHeight + } = componentRef.current; + + const newLeft = scaleX.current + ? // eslint-disable-next-line no-param-reassign + (componentRef.current.scrollLeft = intBetween( + 0, + scrollWidth - clientWidth, + scrollLeft + scaleX.current * strengthMultiplier + )) + : scrollLeft; + + const newTop = scaleY.current + ? // eslint-disable-next-line no-param-reassign + (componentRef.current.scrollTop = intBetween( + 0, + scrollHeight - clientHeight, + scrollTop + scaleY.current * strengthMultiplier + )) + : scrollTop; + + onScrollChange(newLeft, newTop); } - } - - attach() { - this.attached = true; - window.document.body.addEventListener('dragover', this.updateScrolling); - window.document.body.addEventListener('touchmove', this.updateScrolling); - } + frame.current = raf(tick); + }; - detach() { - this.attached = false; - window.document.body.removeEventListener('dragover', this.updateScrolling); - window.document.body.removeEventListener('touchmove', this.updateScrolling); - } + tick(); + }, []); - // Update scaleX and scaleY every 100ms or so - // and start scrolling if necessary - updateScrolling = throttle( - evt => { - const { - left: x, - top: y, - width: w, - height: h - } = this.container.getBoundingClientRect(); - const box = { x, y, w, h }; - const coords = getCoords(evt); - - // calculate strength - this.scaleX = this.props.horizontalStrength(box, coords); - this.scaleY = this.props.verticalStrength(box, coords); - - // start scrolling if we need to - if (!this.frame && (this.scaleX || this.scaleY)) { - this.startScrolling(); - } - }, - 100, - { trailing: false } - ); + // Update scaleX and scaleY every 100ms or so + // and start scrolling if necessary + const updateScrolling = React.useMemo( + () => + throttle( + evt => { + if (!componentRef.current) { + return; + } - startScrolling() { - let i = 0; - const tick = () => { - const { scaleX, scaleY, container } = this; - const { strengthMultiplier, onScrollChange } = this.props; - - // stop scrolling if there's nothing to do - if (strengthMultiplier === 0 || scaleX + scaleY === 0) { - this.stopScrolling(); - return; - } - - // there's a bug in safari where it seems like we can't get - // mousemove events from a container that also emits a scroll - // event that same frame. So we double the strengthMultiplier and only adjust - // the scroll position at 30fps - if (i++ % 2) { const { - scrollLeft, - scrollTop, - scrollWidth, - scrollHeight, - clientWidth, - clientHeight - } = container; - - const newLeft = scaleX - ? (container.scrollLeft = intBetween( - 0, - scrollWidth - clientWidth, - scrollLeft + scaleX * strengthMultiplier - )) - : scrollLeft; - - const newTop = scaleY - ? (container.scrollTop = intBetween( - 0, - scrollHeight - clientHeight, - scrollTop + scaleY * strengthMultiplier - )) - : scrollTop; - - onScrollChange(newLeft, newTop); - } - this.frame = raf(tick); - }; - - tick(); + left: x, + top: y, + width: w, + height: h + } = componentRef.current.getBoundingClientRect(); + const box = { x, y, w, h }; + const coords = getCoords(evt); + + // calculate strength + scaleX.current = props.horizontalStrength(box, coords); + scaleY.current = props.verticalStrength(box, coords); + + // start scrolling if we need to + if (!frame.current && (scaleX.current || scaleY.current)) { + startScrolling(); + } + }, + 100, + { trailing: false } + ), + [] + ); + + const attach = useCallback(() => { + attached.current = true; + window.document.body.addEventListener('dragover', updateScrolling); + window.document.body.addEventListener('touchmove', updateScrolling); + }, []); + + const detach = useCallback(() => { + attached.current = false; + window.document.body.removeEventListener('dragover', updateScrolling); + window.document.body.removeEventListener('touchmove', updateScrolling); + }, []); + + const stopScrolling = React.useCallback(() => { + detach(); + scaleX.current = 0; + scaleY.current = 0; + + if (frame.current) { + raf.cancel(frame.current); + frame.current = null; } + }, []); - stopScrolling() { - this.detach(); - this.scaleX = 0; - this.scaleY = 0; - - if (this.frame) { - raf.cancel(this.frame); - this.frame = null; + const handleEvent = useCallback( + evt => { + if (dragging.current && !attached.current) { + attach(); + updateScrolling(evt); } + }, + [dragDropManager] + ); + + useEffect(() => { + if (!dragging.current && monitor.isDragging()) { + dragging.current = true; + } else if (dragging.current && !monitor.isDragging()) { + dragging.current = false; + stopScrolling(); } + }, [monitor.isDragging()]); - render() { - const { - // not passing down these props - strengthMultiplier, - verticalStrength, - horizontalStrength, - onScrollChange, - - ...props - } = this.props; - - return ( - { - this.wrappedInstance = ref; - }} - {...props} - /> - ); + useEffect(() => { + if (!componentRef.current) { + return () => {}; } + componentRef.current.addEventListener('dragover', handleEvent); + // touchmove events don't seem to work across siblings, so we unfortunately + // have to attach the listeners to the body + window.document.body.addEventListener('touchmove', handleEvent); + + return () => { + if (componentRef.current) { + componentRef.current.removeEventListener('dragover', handleEvent); + } + window.document.body.removeEventListener('touchmove', handleEvent); + stopScrolling(); + }; + }, [dragDropManager]); +} + +export default function createScrollingComponent(WrappedComponent) { + function ScrollingComponent({ + strengthMultiplier, + verticalStrength, + horizontalStrength, + onScrollChange, + dragDropManager, + + ...passedProps + }) { + const ref = useRef(null); + useDndScrolling(ref, { + strengthMultiplier, + verticalStrength, + horizontalStrength, + onScrollChange, + dragDropManager + }); + + return ; } + ScrollingComponent.displayName = `Scrolling(${getDisplayName(WrappedComponent)})`; + ScrollingComponent.propTypes = { + onScrollChange: PropTypes.func, + verticalStrength: PropTypes.func, + horizontalStrength: PropTypes.func, + strengthMultiplier: PropTypes.number, + dragDropManager: PropTypes.any + }; + ScrollingComponent.defaultProps = defaultProps; + return hoist(ScrollingComponent, WrappedComponent); } diff --git a/types/index.d.ts b/types/index.d.ts index 2315621..9c2e898 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { DragDropManager } from 'dnd-core'; export type BoxType = { x: number; @@ -9,17 +10,29 @@ export type BoxType = { export type StrengthFuncton = (box: BoxType, point: number) => number; -export default function withScrolling< - T extends keyof JSX.IntrinsicElements | React.JSXElementConstructor, - P = React.ComponentProps ->( - component: T -): React.ComponentType< - P & { +export function useDndScrolling( + ref: React.Ref, + options: { verticalStrength?: StrengthFuncton; horizontalStrength?: StrengthFuncton; + strengthMultiplier?: number; + onScrollChange?: (newLeft: number, newTop: number) => void; + dragDropManager?: DragDropManager; } ->; +): void; export function createHorizontalStrength(_buffer: number): StrengthFuncton; export function createVerticalStrength(_buffer: number): StrengthFuncton; + +export default function withScrolling< + T extends keyof JSX.IntrinsicElements | React.JSXElementConstructor, + P = React.ComponentProps + >( + component: T +): React.ComponentType< + P & { + verticalStrength?: StrengthFuncton; + horizontalStrength?: StrengthFuncton; + dragDropManager?: DragDropManager; +} + >;