langmuir75
langmuir75

Reputation: 45

How to handle React Svg Drag and Drop with React Hooks

I am trying to implement Drag and Drop on a SVG shape. I succeeded to make it works with using Class Component. Here is the link to the Code Sandbox : https://codesandbox.io/s/qv81pq1roq

But now I would like to extract this logic by using the new React api with a custom Hook that will be able to add this feature to a functional component. I tried many things but nothing works. Here is my last try :

https://codesandbox.io/s/2x2850vjk0

I am suspecting something with the way I add and remove the event listener... So here are my questions :

Do you think, it is event possible to put this DnD SVG logic to a custom Hook ? If it is, do you have any idea what I am doing wrong ?

Upvotes: 4

Views: 4257

Answers (2)

Nathan
Nathan

Reputation: 2047

Here is a generic custom hook I've been using to register drag events on SVG elements.

import { useState, useEffect, useCallback, useRef } from 'react'

// You may need to edit this to serve your specific use case
function getPos(e) {    
  return {
    x: e.pageX,
    y: e.pageY,
  }
}

// from https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state    
function usePrevious(value) {
  const ref = useRef()
  useEffect(() => {
    ref.current = value
  })
  return ref.current
}

export function useDrag({ onDrag, onDragStart, onDragEnd }) {
  const [isDragging, setIsDragging] = useState(false)

  const handleMouseMove = useCallback(
    (e) => {
      onDrag(getPos(e))
    }, 
    [onDrag]
  )

  const handleMouseUp = useCallback(
    (e) => {
      onDragEnd(getPos(e))
      document.removeEventListener('mousemove', handleMouseMove);
      setIsDragging(false)
    }, 
    [onDragEnd, handleMouseMove]
  )

  const handleMouseDown = useCallback(
    (e) => {
      onDragStart(getPos(e))
      setIsDragging(true)
      document.addEventListener('mousemove', handleMouseMove)
    }, 
    [onDragStart, handleMouseMove]
  )

  const prevMouseMove = usePrevious(handleMouseMove)

  useEffect(
    () => {
      document.removeEventListener('mousemove', prevMouseMove);
      if(isDragging) {
        document.addEventListener('mousemove', handleMouseMove)
      }
    },
    [prevMouseMove, handleMouseMove, isDragging]
  )

  useEffect(
    () => {
      if (isDragging) {
        document.addEventListener('mouseup', handleMouseUp)
      }
      return () => document.removeEventListener('mouseup', handleMouseUp)
    },
    [isDragging, handleMouseUp]
  )

  return handleMouseDown
}

Upvotes: 1

Yangshun Tay
Yangshun Tay

Reputation: 53119

I fixed the example here - https://codesandbox.io/s/2w0oy6qnvn

There were a number of issues in your hooks example:

  1. setPosition is different from setState. It doesn't do a shallow merge, it replaces the entire object with the new value, so you have to use Object.assign() or spread operator to merge with the previous value. Also, the setPosition() hook takes a callback value which provides the previous state value as a first parameter if you need to reference it when setting the new value.

  2. Unlike in classes, the handleMouseMove function is recreated in every render, so document.removeEventListener('mousemove', handleMouseMove) is no longer referencing the initial handleMouseMove value when document.addEventListener('mousemove', handleMouseMove) was invoked. The workaround for this is to use useRef which creates an object that persists throughout the lifetime of the component, perfect for retaining reference to functions.

  3. The event parameter in handleMouseDown and the one you reference in setPosition aren't the same. Because React uses event pooling and reuses the events, the event in setPosition could already be different from the one passed into handleMouseDown. The way around this is to get the values of pageX and pageY first so that within setPosition it doesn't need to rely on the event object.

I annotated the code below with the parts which you need to take note of.

const Circle = () => {
  const [position, setPosition] = React.useState({
    x: 50,
    y: 50,
    coords: {},
  });
  
  // Use useRef to create the function once and hold a reference to it.
  const handleMouseMove = React.useRef(e => {
    setPosition(position => {
      const xDiff = position.coords.x - e.pageX;
      const yDiff = position.coords.y - e.pageY;
      return {
        x: position.x - xDiff,
        y: position.y - yDiff,
        coords: {
          x: e.pageX,
          y: e.pageY,
        },
      };
    });
  });

  const handleMouseDown = e => {
    // Save the values of pageX and pageY and use it within setPosition.
    const pageX = e.pageX; 
    const pageY = e.pageY;
    setPosition(position => Object.assign({}, position, {
      coords: {
        x: pageX,
        y: pageY,
      },
    }));
    document.addEventListener('mousemove', handleMouseMove.current);
  };

  const handleMouseUp = () => {
    document.removeEventListener('mousemove', handleMouseMove.current);
    // Use Object.assign to do a shallow merge so as not to 
    // totally overwrite the other values in state.
    setPosition(position =>
      Object.assign({}, position, {
        coords: {},
      })
    );
  };

  return (
    <circle
      cx={position.x}
      cy={position.y}
      r={25}
      fill="black"
      stroke="black"
      strokeWidth="1"
      onMouseDown={handleMouseDown}
      onMouseUp={handleMouseUp}
    />
  );
};

const App = () => {
  return (
    <svg
      style={{
        border: '1px solid green',
        height: '200px',
        width: '100%',
      }}
    >
      <Circle />
    </svg>
  );
};

ReactDOM.render(<App />, document.querySelector('#app'));
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

Upvotes: 6

Related Questions