Reputation: 1917
I want to make a draggable (that is, repositionable by mouse) React component, which seems to necessarily involve global state and scattered event handlers. I can do it the dirty way, with a global variable in my JS file, and could probably even wrap it in a nice closure interface, but I want to know if there's a way that meshes with React better.
Also, since I've never done this in raw JavaScript before, I'd like to see how an expert does it, to make sure I've got all the corner cases handled, especially as they relate to React.
Thanks.
Upvotes: 170
Views: 286652
Reputation: 171
This is the simplest component for making an element draggable. Insert any element you want to make draggable inside this component and it will work.
import { useEffect, useState } from "react";
const DragAndDrop = ({ children }) => {
const [isDragging, setIsDragging] = useState(false);
const [xTranslate, setXTranslate] = useState(0);
const [yTranslate, setYTranslate] = useState(0);
const [initialMousePosition, setInitialMousePosition] = useState({});
const onMouseDown = ({ clientX, clientY }) => {
setInitialMousePosition({ x: clientX, y: clientY });
setIsDragging(true);
};
useEffect(() => {
const onMouseMove = (e) => {
setXTranslate(xTranslate + e.clientX - initialMousePosition.x);
setYTranslate(yTranslate + e.clientY - initialMousePosition.y);
};
if (isDragging) {
window.addEventListener("mousemove", onMouseMove);
}
return () => window.removeEventListener("mousemove", onMouseMove);
}, [isDragging, initialMousePosition]);
useEffect(() => {
const onMouseUp = () => setIsDragging(false);
window.addEventListener("mouseup", onMouseUp);
return () => window.removeEventListener("mouseup", onMouseUp);
}, []);
return (
<div
style={{ transform: `translate(${xTranslate}px,${yTranslate}px)` }}
onMouseDown={onMouseDown}
>
{" "}
{children}
</div>
);
};
export default DragAndDrop;
For example like this:
//In your main file:
<DragAndDrop>
<div>I am draggable🙂</div>
</DragAndDrop>
Upvotes: 4
Reputation: 13162
I should probably turn this into a blog post, but here's a pretty solid example.
The comments should explain things pretty well, but let me know if you have questions.
And here's the fiddle to play with: https://jsfiddle.net/Af9Jt/2/
var Draggable = React.createClass({
getDefaultProps: function () {
return {
// allow the initial position to be passed in as a prop
initialPos: {x: 0, y: 0}
}
},
getInitialState: function () {
return {
pos: this.props.initialPos,
dragging: false,
rel: null // position relative to the cursor
}
},
// we could get away with not having this (and just having the listeners on
// our div), but then the experience would be possibly be janky. If there's
// anything w/ a higher z-index that gets in the way, then you're toast,
// etc.
componentDidUpdate: function (props, state) {
if (this.state.dragging && !state.dragging) {
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.onMouseUp)
} else if (!this.state.dragging && state.dragging) {
document.removeEventListener('mousemove', this.onMouseMove)
document.removeEventListener('mouseup', this.onMouseUp)
}
},
// calculate relative position of the mouse and set dragging=true
onMouseDown: function (e) {
// only left mouse button
if (e.button !== 0) return
var pos = $(this.getDOMNode()).offset()
this.setState({
dragging: true,
rel: {
x: e.pageX - pos.left,
y: e.pageY - pos.top
}
})
e.stopPropagation()
e.preventDefault()
},
onMouseUp: function (e) {
this.setState({dragging: false})
e.stopPropagation()
e.preventDefault()
},
onMouseMove: function (e) {
if (!this.state.dragging) return
this.setState({
pos: {
x: e.pageX - this.state.rel.x,
y: e.pageY - this.state.rel.y
}
})
e.stopPropagation()
e.preventDefault()
},
render: function () {
// transferPropsTo will merge style & other props passed into our
// component to also be on the child DIV.
return this.transferPropsTo(React.DOM.div({
onMouseDown: this.onMouseDown,
style: {
left: this.state.pos.x + 'px',
top: this.state.pos.y + 'px'
}
}, this.props.children))
}
})
"Who should own what state" is an important question to answer, right from the start. In the case of a "draggable" component, I could see a few different scenarios.
The parent should own the current position of the draggable. In this case, the draggable would still own its own "am I being dragged" state, but would call this.props.onChange(x, y)
whenever a mousemove
event occurs.
The parent only needs to own the "non-moving position", and so the draggable would own it's "dragging position" but onmouseup
would call this.props.onChange(x, y)
and defer the final decision to the parent. If the parent doesn't like where the draggable ended up, it would reject the state update, and the draggable would "snap back" to its initial position.
@ssorallen pointed out that because draggable
is more of an attribute than a thing in itself, it might serve better as a mixin. My experience with mixins is limited, so I haven't seen how they might help or get in the way in complicated situations. This might very well be the best option.
Upvotes: 152
Reputation: 373
There's already plenty of answers, but I'll throw in mine as well. The advantages of this answer is as follows:
import {
CSSProperties,
useEffect,
useRef,
useState,
MouseEvent as r_MouseEvent,
MutableRefObject,
} from 'react';
interface PositionType {
x: number,
y: number,
}
interface MinMaxType {
min: number,
max: number,
}
interface Props {
text: string,
position: PositionType
isDragging?: boolean,
style?: CSSProperties,
}
const clamp = (num: number, min: number, max: number): number => Math.min(max, Math.max(min, num));
const Draggable = ({
text,
position,
style = {},
}: Props) => {
const [pos, setPos] = useState<PositionType>();
const draggableRef = useRef<HTMLDivElement>();
const [parent, setParent] = useState<HTMLElement | null>();
const [xBounds, setXBounds] = useState<MinMaxType>({ min: 0, max: 0 });
const [yBounds, setYBounds] = useState<MinMaxType>({ min: 0, max: 0 });
useEffect(() => {
const parentElement: HTMLDivElement = draggableRef?.current?.parentElement as HTMLDivElement;
const parentWidth: number = parentElement?.offsetWidth as number;
const parentHeight: number = parentElement?.offsetHeight as number;
const parentLeft: number = parentElement?.offsetLeft as number;
const parentTop: number = parentElement?.offsetTop as number;
const draggableWidth: number = draggableRef?.current?.offsetWidth as number;
const draggableHeight: number = draggableRef?.current?.offsetHeight as number;
setParent(parentElement);
setPos({
x: parentLeft + position.x,
y: parentTop + position.y
});
setXBounds({
min: parentLeft,
max: parentWidth + parentLeft - draggableWidth,
});
setYBounds({
min: parentTop,
max: parentHeight + parentTop - draggableHeight,
});
}, [draggableRef, setParent, setPos, setXBounds, setYBounds, position]);
const mouseDownHandler = (e: r_MouseEvent) => {
if (e.button !== 0) return // only left mouse button
parent?.addEventListener('mousemove', mouseMoveHandler);
parent?.addEventListener('mouseup', mouseUpHandler);
parent?.addEventListener('mouseleave', mouseUpHandler);
e.stopPropagation();
e.preventDefault();
};
const mouseMoveHandler = (e: MouseEvent) => {
setPos({
x: clamp(e.pageX, xBounds?.min, xBounds?.max),
y: clamp(e.pageY, yBounds?.min, yBounds?.max),
});
e.stopPropagation();
e.preventDefault();
};
const mouseUpHandler = (e: MouseEvent) => {
parent?.removeEventListener('mousemove', mouseMoveHandler);
parent?.removeEventListener('mouseup', mouseUpHandler);
e.stopPropagation();
e.preventDefault();
};
const positionStyle = pos && { left: `${pos.x}px`, top: `${pos.y}px` };
const draggableStyle = { ..._styles.draggable, ...positionStyle, ...style } as CSSProperties;
return (
<div ref = { draggableRef as MutableRefObject <HTMLDivElement> }
style = { draggableStyle }
onMouseDown = {mouseDownHandler}>
{ text }
</div>
);
}
const _styles = {
draggable: {
position: 'absolute',
padding: '2px',
border: '1px solid black',
borderRadius: '5px',
},
};
export default Draggable;
Upvotes: 1
Reputation: 6632
The answer by @codewithfeeling is horribly wrong and lags your page! Here's a version of his code with issues fixed and annotated. This should be the most up to date hook-based answer here now.
import React, { useRef, useState, useEffect, useCallback } from "react";
/// throttle.ts
export const throttle = (f) => {
let token = null,
lastArgs = null;
const invoke = () => {
f(...lastArgs);
token = null;
};
const result = (...args) => {
lastArgs = args;
if (!token) {
token = requestAnimationFrame(invoke);
}
};
result.cancel = () => token && cancelAnimationFrame(token);
return result;
};
/// use-draggable.ts
const id = (x) => x;
// complex logic should be a hook, not a component
const useDraggable = ({ onDrag = id } = {}) => {
// this state doesn't change often, so it's fine
const [pressed, setPressed] = useState(false);
// do not store position in useState! even if you useEffect on
// it and update `transform` CSS property, React still rerenders
// on every state change, and it LAGS
const position = useRef({ x: 0, y: 0 });
const ref = useRef();
// we've moved the code into the hook, and it would be weird to
// return `ref` and `handleMouseDown` to be set on the same element
// why not just do the job on our own here and use a function-ref
// to subscribe to `mousedown` too? it would go like this:
const unsubscribe = useRef();
const legacyRef = useCallback((elem) => {
// in a production version of this code I'd use a
// `useComposeRef` hook to compose function-ref and object-ref
// into one ref, and then would return it. combining
// hooks in this way by hand is error-prone
// then I'd also split out the rest of this function into a
// separate hook to be called like this:
// const legacyRef = useDomEvent('mousedown');
// const combinedRef = useCombinedRef(ref, legacyRef);
// return [combinedRef, pressed];
ref.current = elem;
if (unsubscribe.current) {
unsubscribe.current();
}
if (!elem) {
return;
}
const handleMouseDown = (e) => {
// don't forget to disable text selection during drag and drop
// operations
e.target.style.userSelect = "none";
setPressed(true);
};
elem.addEventListener("mousedown", handleMouseDown);
unsubscribe.current = () => {
elem.removeEventListener("mousedown", handleMouseDown);
};
}, []);
// useEffect(() => {
// return () => {
// // this shouldn't really happen if React properly calls
// // function-refs, but I'm not proficient enough to know
// // for sure, and you might get a memory leak out of it
// if (unsubscribe.current) {
// unsubscribe.current();
// }
// };
// }, []);
useEffect(() => {
// why subscribe in a `useEffect`? because we want to subscribe
// to mousemove only when pressed, otherwise it will lag even
// when you're not dragging
if (!pressed) {
return;
}
// updating the page without any throttling is a bad idea
// requestAnimationFrame-based throttle would probably be fine,
// but be aware that naive implementation might make element
// lag 1 frame behind cursor, and it will appear to be lagging
// even at 60 FPS
const handleMouseMove = throttle((event) => {
// needed for TypeScript anyway
if (!ref.current || !position.current) {
return;
}
const pos = position.current;
// it's important to save it into variable here,
// otherwise we might capture reference to an element
// that was long gone. not really sure what's correct
// behavior for a case when you've been scrolling, and
// the target element was replaced. probably some formulae
// needed to handle that case. TODO
const elem = ref.current;
position.current = onDrag({
x: pos.x + event.movementX,
y: pos.y + event.movementY
});
elem.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
});
const handleMouseUp = (e) => {
e.target.style.userSelect = "auto";
setPressed(false);
};
// subscribe to mousemove and mouseup on document, otherwise you
// can escape bounds of element while dragging and get stuck
// dragging it forever
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
handleMouseMove.cancel();
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
// if `onDrag` wasn't defined with `useCallback`, we'd have to
// resubscribe to 2 DOM events here, not to say it would mess
// with `throttle` and reset its internal timer
}, [pressed, onDrag]);
// actually it makes sense to return an array only when
// you expect that on the caller side all of the fields
// will be usually renamed
return [legacyRef, pressed];
// > seems the best of them all to me
// this code doesn't look pretty anymore, huh?
};
/// example.ts
const quickAndDirtyStyle = {
width: "200px",
height: "200px",
background: "#FF9900",
color: "#FFFFFF",
display: "flex",
justifyContent: "center",
alignItems: "center"
};
const DraggableComponent = () => {
// handlers must be wrapped into `useCallback`. even though
// resubscribing to `mousedown` on every tick is quite cheap
// due to React's event system, `handleMouseDown` might be used
// in `deps` argument of another hook, where it would really matter.
// as you never know where return values of your hook might end up,
// it's just generally a good idea to ALWAYS use `useCallback`
// it's nice to have a way to at least prevent element from
// getting dragged out of the page
const handleDrag = useCallback(
({ x, y }) => ({
x: Math.max(0, x),
y: Math.max(0, y)
}),
[]
);
const [ref, pressed] = useDraggable({
onDrag: handleDrag
});
return (
<div ref={ref} style={quickAndDirtyStyle}>
<p>{pressed ? "Dragging..." : "Press to drag"}</p>
</div>
);
};
See this code live here, a version with improved positioning of cursor with constrained onDrag
here and hardcore hook showcase here.
(Previously this answer was about pre-hook React, and told the answer by Jared Forsyth is horribly wrong. It doesn't matter the least now, but it's still in edit history of the answer.)
Upvotes: 52
Reputation: 1
Do Not Use React Component and useEffect Hook for implementing the functionality of Dragging Containers
Here is the React Class Based Component ES6 Version -->
import React from "react";
import $ from 'jquery';
import { useRef } from "react";
class Temp_Class extends React.Component{
constructor(props){
super(props);
this.state = {
pos: {x:0, y:0},
dragging: false,
rel: null
};
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
}
componentDidUpdate(props, state){
// console.log("Dragging State is ",this.state)
if (this.state.dragging && !state.dragging) {
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.onMouseUp)
} else if (!this.state.dragging && state.dragging) {
document.removeEventListener('mousemove', this.onMouseMove)
document.removeEventListener('mouseup', this.onMouseUp)
}
}
onMouseDown(e){
console.log("Mouse Down")
if (e.button !== 0) return
var pos = document.getElementById("contianer").getBoundingClientRect();
// console.log(pos)
this.setState({
dragging: true,
rel: {
x: e.pageX - pos.left,
y: e.pageY - pos.top
}
})
e.stopPropagation()
e.preventDefault()
}
onMouseUp(e) {
console.log("Mouse Up")
this.setState({dragging: false})
e.stopPropagation()
e.preventDefault()
}
onMouseMove(e) {
console.log("Mouse Move")
if (!this.state.dragging) return
this.setState({
pos: {
x: e.pageX - this.state.rel.x,
y: e.pageY - this.state.rel.y
}
})
e.stopPropagation()
e.preventDefault()
console.log("Current State is ", this.state)
}
render(){
return (<div id="contianer" style = {{
position: 'absolute',
left: this.state.pos.x + 'px',
top: this.state.pos.y + 'px',
cursor: 'pointer',
width: '200px',
height: '200px',
backgroundColor: '#cca',
}} onMouseDown = {this.onMouseDown}>
Lovepreet Singh
</div>);
}
}
export default Temp_Class;
Upvotes: -1
Reputation: 1
Here is a dragable div example (tested) using react functional
function Draggable() {
const startX = 300;
const startY = 200;
const [pos, setPos] = useState({ left: startX , top: startY });
const [isDragging, setDragging] = useState(false);
const isDraggingRef = React.useRef(isDragging);
const setDraggingState = (data) => {
isDraggingRef.current = data;
setDragging(data);
};
function onMouseDown(e) {
setDraggingState(true);
e.stopPropagation();
e.preventDefault();
}
function onMouseMove(e) {
if (isDraggingRef.current) {
const rect = e.target.parentNode.getBoundingClientRect();
let newLeft = e.pageX - rect.left - 20;
let newTop = e.pageY - rect.top - 20;
if (
newLeft > 0 &&
newTop > 0 &&
newLeft < rect.width &&
newTop < rect.height
) {
setPos({
left: newLeft,
top: newTop,
});
} else setDraggingState(false);
}
e.stopPropagation();
e.preventDefault();
}
function onMouseUp(e) {
setDraggingState(false);
e.stopPropagation();
e.preventDefault();
}
useEffect(() => {
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}, []);
useEffect(() => {
console.log(pos)
}, [pos]);
return <div style={pos} className="draggableDiv" onMouseDown={onMouseDown}></div>;
}
Upvotes: 0
Reputation: 7874
Here is a simple another React hooks solution without any third party libraries, based on codewithfeeling and Evan Conrad's solutions. https://stackoverflow.com/a/63887486/1309218 https://stackoverflow.com/a/61667523/1309218
import React, { useCallback, useRef, useState } from "react";
import styled, { css } from "styled-components/macro";
const Component: React.FC = () => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const elementRef = useRef<HTMLDivElement>(null);
const onMouseDown = useCallback(
(event) => {
const onMouseMove = (event: MouseEvent) => {
position.x += event.movementX;
position.y += event.movementY;
const element = elementRef.current;
if (element) {
element.style.transform = `translate(${position.x}px, ${position.y}px)`;
}
setPosition(position);
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
[position, setPosition, elementRef]
);
return (
<Container>
<DraggableItem ref={elementRef} onMouseDown={onMouseDown}>
</DraggableItem>
</Container>
);
};
const Container = styled.div`
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
overflow: hidden;
`;
const DraggableItem = styled.div`
position: absolute;
z-index: 1;
left: 20px;
top: 20px;
width: 100px;
height: 100px;
background-color: green;
`;
Upvotes: 5
Reputation: 1349
Elaborating on Evan Conrad's answer (https://stackoverflow.com/a/63887486/1531141) I came to this Typescript approach:
import { RefObject, useEffect, useRef, useState } from "react";
export enum DraggingState {
undefined = -1,
starts = 0,
moves = 1,
finished = 2
}
export default function useDragging() {
const [state, setState] = useState(DraggingState.undefined);
const [point, setPoint] = useState({x: 0, y: 0}); // point of cursor in relation to the element's parent
const [elementOffset, setElementOffset] = useState({x: 0, y: 0}); // offset of element in relation to it's parent
const [touchOffset, setTouchOffset] = useState({x: 0, y: 0}); // offset of mouse down point in relation to the element
const ref = useRef() as RefObject<HTMLDivElement>;
// shows active state of dragging
const isDragging = () => {
return (state === DraggingState.starts) || (state === DraggingState.moves);
}
function onMouseDown(e: MouseEvent) {
const parentElement = ref.current?.offsetParent as HTMLElement;
if (e.button !== 0 || !ref.current || !parentElement) return;
// First entry to the flow.
// We save touchOffset value as parentElement's offset
// to calculate element's offset on the move.
setPoint({
x: e.x - parentElement.offsetLeft,
y: e.y - parentElement.offsetTop
});
setElementOffset({
x: ref.current.offsetLeft,
y: ref.current.offsetTop
});
setTouchOffset({
x: e.x - parentElement.offsetLeft - ref.current.offsetLeft,
y: e.y - parentElement.offsetTop - ref.current.offsetTop
});
setState(DraggingState.starts);
}
function onMouseMove(e: MouseEvent) {
const parentElement = ref.current?.offsetParent as HTMLElement;
if (!isDragging() || !ref.current || !parentElement) return;
setState(DraggingState.moves);
setPoint({
x: e.x - parentElement.offsetLeft,
y: e.y - parentElement.offsetTop
});
setElementOffset({
x: e.x - touchOffset.x - parentElement.offsetLeft,
y: e.y - touchOffset.y - parentElement.offsetTop
});
}
function onMouseUp(e: MouseEvent) {
// ends up the flow by setting the state
setState(DraggingState.finished);
}
function onClick(e: MouseEvent) {
// that's a fix for touch pads that transfer touches to click,
// e.g "Tap to click" on macos. When enabled, on tap mouseDown is fired,
// but mouseUp isn't. In this case we invoke mouseUp manually, to trigger
// finishing state;
setState(DraggingState.finished);
}
// When the element mounts, attach an mousedown listener
useEffect(() => {
const element = ref.current;
element?.addEventListener("mousedown", onMouseDown);
return () => {
element?.removeEventListener("mousedown", onMouseDown);
};
}, [ref.current]);
// Everytime the state changes, assign or remove
// the corresponding mousemove, mouseup and click handlers
useEffect(() => {
if (isDragging()) {
document.addEventListener("mouseup", onMouseUp);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("click", onClick);
} else {
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("click", onClick);
}
return () => {
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("click", onClick);
};
}, [state]);
return {
ref: ref,
state: state,
point: point,
elementOffset: elementOffset,
touchOffset: touchOffset
}
}
also added onClick handler as on touchpads with tap to click option enabled both onClick and mouseDown happen on the same moment, but mouseUp never gets fired up to close up the gesture.
Also, this hook returns three pairs of coords - element offset to its parent, grab point inside the element and a point inside the element's parent. See the comments inside the code for details;
Used like this:
const dragging = useDragging();
const ref = dragging.ref;
const style: CSSProperties = {
marginLeft: dragging.elementOffset.x,
marginTop: dragging.elementOffset.y,
border: "1px dashed red"
}
return (<div ref={ref} style={style}>
{dragging.state === DraggingState.moves ? "is dragging" : "not dragging"}
</div>)
Upvotes: 0
Reputation: 4389
Here's a 2020 answer with a Hook:
function useDragging() {
const [isDragging, setIsDragging] = useState(false);
const [pos, setPos] = useState({ x: 0, y: 0 });
const ref = useRef(null);
function onMouseMove(e) {
if (!isDragging) return;
setPos({
x: e.x - ref.current.offsetWidth / 2,
y: e.y - ref.current.offsetHeight / 2,
});
e.stopPropagation();
e.preventDefault();
}
function onMouseUp(e) {
setIsDragging(false);
e.stopPropagation();
e.preventDefault();
}
function onMouseDown(e) {
if (e.button !== 0) return;
setIsDragging(true);
setPos({
x: e.x - ref.current.offsetWidth / 2,
y: e.y - ref.current.offsetHeight / 2,
});
e.stopPropagation();
e.preventDefault();
}
// When the element mounts, attach an mousedown listener
useEffect(() => {
ref.current.addEventListener("mousedown", onMouseDown);
return () => {
ref.current.removeEventListener("mousedown", onMouseDown);
};
}, [ref.current]);
// Everytime the isDragging state changes, assign or remove
// the corresponding mousemove and mouseup handlers
useEffect(() => {
if (isDragging) {
document.addEventListener("mouseup", onMouseUp);
document.addEventListener("mousemove", onMouseMove);
} else {
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("mousemove", onMouseMove);
}
return () => {
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("mousemove", onMouseMove);
};
}, [isDragging]);
return [ref, pos.x, pos.y, isDragging];
}
Then a component that uses the hook:
function Draggable() {
const [ref, x, y, isDragging] = useDragging();
return (
<div
ref={ref}
style={{
position: "absolute",
width: 50,
height: 50,
background: isDragging ? "blue" : "gray",
left: x,
top: y,
}}
></div>
);
}
Upvotes: 17
Reputation: 51
I've updated the class using refs as all the solutions I see on here have things that are no longer supported or will soon be depreciated like ReactDOM.findDOMNode
. Can be parent to a child component or a group of children :)
import React, { Component } from 'react';
class Draggable extends Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
this.state = {
counter: this.props.counter,
pos: this.props.initialPos,
dragging: false,
rel: null // position relative to the cursor
};
}
/* we could get away with not having this (and just having the listeners on
our div), but then the experience would be possibly be janky. If there's
anything w/ a higher z-index that gets in the way, then you're toast,
etc.*/
componentDidUpdate(props, state) {
if (this.state.dragging && !state.dragging) {
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
} else if (!this.state.dragging && state.dragging) {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
}
}
// calculate relative position to the mouse and set dragging=true
onMouseDown = (e) => {
if (e.button !== 0) return;
let pos = { left: this.myRef.current.offsetLeft, top: this.myRef.current.offsetTop }
this.setState({
dragging: true,
rel: {
x: e.pageX - pos.left,
y: e.pageY - pos.top
}
});
e.stopPropagation();
e.preventDefault();
}
onMouseUp = (e) => {
this.setState({ dragging: false });
e.stopPropagation();
e.preventDefault();
}
onMouseMove = (e) => {
if (!this.state.dragging) return;
this.setState({
pos: {
x: e.pageX - this.state.rel.x,
y: e.pageY - this.state.rel.y
}
});
e.stopPropagation();
e.preventDefault();
}
render() {
return (
<span ref={this.myRef} onMouseDown={this.onMouseDown} style={{ position: 'absolute', left: this.state.pos.x + 'px', top: this.state.pos.y + 'px' }}>
{this.props.children}
</span>
)
}
}
export default Draggable;
Upvotes: 3
Reputation: 6576
Here's a simple modern approach to this with useState
, useEffect
and useRef
in ES6.
import React, { useRef, useState, useEffect } from 'react'
const quickAndDirtyStyle = {
width: "200px",
height: "200px",
background: "#FF9900",
color: "#FFFFFF",
display: "flex",
justifyContent: "center",
alignItems: "center"
}
const DraggableComponent = () => {
const [pressed, setPressed] = useState(false)
const [position, setPosition] = useState({x: 0, y: 0})
const ref = useRef()
// Monitor changes to position state and update DOM
useEffect(() => {
if (ref.current) {
ref.current.style.transform = `translate(${position.x}px, ${position.y}px)`
}
}, [position])
// Update the current position if mouse is down
const onMouseMove = (event) => {
if (pressed) {
setPosition({
x: position.x + event.movementX,
y: position.y + event.movementY
})
}
}
return (
<div
ref={ ref }
style={ quickAndDirtyStyle }
onMouseMove={ onMouseMove }
onMouseDown={ () => setPressed(true) }
onMouseUp={ () => setPressed(false) }>
<p>{ pressed ? "Dragging..." : "Press to drag" }</p>
</div>
)
}
export default DraggableComponent
Upvotes: 27
Reputation: 9058
react-draggable is also easy to use. Github:
https://github.com/mzabriskie/react-draggable
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import Draggable from 'react-draggable';
var App = React.createClass({
render() {
return (
<div>
<h1>Testing Draggable Windows!</h1>
<Draggable handle="strong">
<div className="box no-cursor">
<strong className="cursor">Drag Here</strong>
<div>You must click my handle to drag me</div>
</div>
</Draggable>
</div>
);
}
});
ReactDOM.render(
<App />, document.getElementById('content')
);
And my index.html:
<html>
<head>
<title>Testing Draggable Windows</title>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<div id="content"></div>
<script type="text/javascript" src="bundle.js" charset="utf-8"></script>
<script src="http://localhost:8080/webpack-dev-server.js"></script>
</body>
</html>
You need their styles, which is short, or you don't get quite the expected behavior. I like the behavior more than some of the other possible choices, but there's also something called react-resizable-and-movable. I'm trying to get resize working with draggable, but no joy so far.
Upvotes: 18
Reputation: 311
I've updated polkovnikov.ph solution to React 16 / ES6 with enhancements like touch handling and snapping to a grid which is what I need for a game. Snapping to a grid alleviates the performance issues.
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
class Draggable extends React.Component {
constructor(props) {
super(props);
this.state = {
relX: 0,
relY: 0,
x: props.x,
y: props.y
};
this.gridX = props.gridX || 1;
this.gridY = props.gridY || 1;
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchMove = this.onTouchMove.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
}
static propTypes = {
onMove: PropTypes.func,
onStop: PropTypes.func,
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
gridX: PropTypes.number,
gridY: PropTypes.number
};
onStart(e) {
const ref = ReactDOM.findDOMNode(this.handle);
const body = document.body;
const box = ref.getBoundingClientRect();
this.setState({
relX: e.pageX - (box.left + body.scrollLeft - body.clientLeft),
relY: e.pageY - (box.top + body.scrollTop - body.clientTop)
});
}
onMove(e) {
const x = Math.trunc((e.pageX - this.state.relX) / this.gridX) * this.gridX;
const y = Math.trunc((e.pageY - this.state.relY) / this.gridY) * this.gridY;
if (x !== this.state.x || y !== this.state.y) {
this.setState({
x,
y
});
this.props.onMove && this.props.onMove(this.state.x, this.state.y);
}
}
onMouseDown(e) {
if (e.button !== 0) return;
this.onStart(e);
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
e.preventDefault();
}
onMouseUp(e) {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
this.props.onStop && this.props.onStop(this.state.x, this.state.y);
e.preventDefault();
}
onMouseMove(e) {
this.onMove(e);
e.preventDefault();
}
onTouchStart(e) {
this.onStart(e.touches[0]);
document.addEventListener('touchmove', this.onTouchMove, {passive: false});
document.addEventListener('touchend', this.onTouchEnd, {passive: false});
e.preventDefault();
}
onTouchMove(e) {
this.onMove(e.touches[0]);
e.preventDefault();
}
onTouchEnd(e) {
document.removeEventListener('touchmove', this.onTouchMove);
document.removeEventListener('touchend', this.onTouchEnd);
this.props.onStop && this.props.onStop(this.state.x, this.state.y);
e.preventDefault();
}
render() {
return <div
onMouseDown={this.onMouseDown}
onTouchStart={this.onTouchStart}
style={{
position: 'absolute',
left: this.state.x,
top: this.state.y,
touchAction: 'none'
}}
ref={(div) => { this.handle = div; }}
>
{this.props.children}
</div>;
}
}
export default Draggable;
Upvotes: 18
Reputation: 268215
I implemented react-dnd, a flexible HTML5 drag-and-drop mixin for React with full DOM control.
Existing drag-and-drop libraries didn't fit my use case so I wrote my own. It's similar to the code we've been running for about a year on Stampsy.com, but rewritten to take advantage of React and Flux.
Key requirements I had:
If these sound familiar to you, read on.
First, declare types of data that can be dragged.
These are used to check “compatibility” of drag sources and drop targets:
// ItemTypes.js
module.exports = {
BLOCK: 'block',
IMAGE: 'image'
};
(If you don't have multiple data types, this libary may not be for you.)
Then, let's make a very simple draggable component that, when dragged, represents IMAGE
:
var { DragDropMixin } = require('react-dnd'),
ItemTypes = require('./ItemTypes');
var Image = React.createClass({
mixins: [DragDropMixin],
configureDragDrop(registerType) {
// Specify all supported types by calling registerType(type, { dragSource?, dropTarget? })
registerType(ItemTypes.IMAGE, {
// dragSource, when specified, is { beginDrag(), canDrag()?, endDrag(didDrop)? }
dragSource: {
// beginDrag should return { item, dragOrigin?, dragPreview?, dragEffect? }
beginDrag() {
return {
item: this.props.image
};
}
}
});
},
render() {
// {...this.dragSourceFor(ItemTypes.IMAGE)} will expand into
// { draggable: true, onDragStart: (handled by mixin), onDragEnd: (handled by mixin) }.
return (
<img src={this.props.image.url}
{...this.dragSourceFor(ItemTypes.IMAGE)} />
);
}
);
By specifying configureDragDrop
, we tell DragDropMixin
the drag-drop behavior of this component. Both draggable and droppable components use the same mixin.
Inside configureDragDrop
, we need to call registerType
for each of our custom ItemTypes
that component supports. For example, there might be several representations of images in your app, and each would provide a dragSource
for ItemTypes.IMAGE
.
A dragSource
is just an object specifying how the drag source works. You must implement beginDrag
to return item that represents the data you're dragging and, optionally, a few options that adjust the dragging UI. You can optionally implement canDrag
to forbid dragging, or endDrag(didDrop)
to execute some logic when the drop has (or has not) occured. And you can share this logic between components by letting a shared mixin generate dragSource
for them.
Finally, you must use {...this.dragSourceFor(itemType)}
on some (one or more) elements in render
to attach drag handlers. This means you can have several “drag handles” in one element, and they may even correspond to different item types. (If you're not familiar with JSX Spread Attributes syntax, check it out).
Let's say we want ImageBlock
to be a drop target for IMAGE
s. It's pretty much the same, except that we need to give registerType
a dropTarget
implementation:
var { DragDropMixin } = require('react-dnd'),
ItemTypes = require('./ItemTypes');
var ImageBlock = React.createClass({
mixins: [DragDropMixin],
configureDragDrop(registerType) {
registerType(ItemTypes.IMAGE, {
// dropTarget, when specified, is { acceptDrop(item)?, enter(item)?, over(item)?, leave(item)? }
dropTarget: {
acceptDrop(image) {
// Do something with image! for example,
DocumentActionCreators.setImage(this.props.blockId, image);
}
}
});
},
render() {
// {...this.dropTargetFor(ItemTypes.IMAGE)} will expand into
// { onDragEnter: (handled by mixin), onDragOver: (handled by mixin), onDragLeave: (handled by mixin), onDrop: (handled by mixin) }.
return (
<div {...this.dropTargetFor(ItemTypes.IMAGE)}>
{this.props.image &&
<img src={this.props.image.url} />
}
</div>
);
}
);
Say we now want the user to be able to drag out an image out of ImageBlock
. We just need to add appropriate dragSource
to it and a few handlers:
var { DragDropMixin } = require('react-dnd'),
ItemTypes = require('./ItemTypes');
var ImageBlock = React.createClass({
mixins: [DragDropMixin],
configureDragDrop(registerType) {
registerType(ItemTypes.IMAGE, {
// Add a drag source that only works when ImageBlock has an image:
dragSource: {
canDrag() {
return !!this.props.image;
},
beginDrag() {
return {
item: this.props.image
};
}
}
dropTarget: {
acceptDrop(image) {
DocumentActionCreators.setImage(this.props.blockId, image);
}
}
});
},
render() {
return (
<div {...this.dropTargetFor(ItemTypes.IMAGE)}>
{/* Add {...this.dragSourceFor} handlers to a nested node */}
{this.props.image &&
<img src={this.props.image.url}
{...this.dragSourceFor(ItemTypes.IMAGE)} />
}
</div>
);
}
);
I have not covered everything but it's possible to use this API in a few more ways:
getDragState(type)
and getDropState(type)
to learn if dragging is active and use it to toggle CSS classes or attributes;dragPreview
to be Image
to use images as drag placeholders (use ImagePreloaderMixin
to load them);ImageBlocks
reorderable. We only need them to implement dropTarget
and dragSource
for ItemTypes.BLOCK
.dropTargetFor(...types)
allows to specify several types at once, so one drop zone can catch many different types.For up-to-date documentation and installation instructions, head to react-dnd repo on Github.
Upvotes: 74
Reputation: 2544
I would like to add a 3rd Scenario
The moving position is not saved in any way. Think of it as a mouse movement - your cursor is not a React-component, right?
All you do, is to add a prop like "draggable" to your component and a stream of the dragging events that will manipulate the dom.
setXandY: function(event) {
// DOM Manipulation of x and y on your node
},
componentDidMount: function() {
if(this.props.draggable) {
var node = this.getDOMNode();
dragStream(node).onValue(this.setXandY); //baconjs stream
};
},
In this case, a DOM manipulation is an elegant thing (I never thought I'd say this)
Upvotes: 2