Reputation: 45
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
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
Reputation: 53119
I fixed the example here - https://codesandbox.io/s/2w0oy6qnvn
There were a number of issues in your hooks example:
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.
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.
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