react-pointer-draggable
Version:
React draggable component with pointer events
680 lines (603 loc) • 18.1 kB
JavaScript
'use strict';
var React = require('react/addons');
var emptyFunction = function () {};
// for accessing browser globals
var root = typeof window !== 'undefined' ? window : this;
var bodyElement;
if (typeof document !== 'undefined' && 'body' in document) {
bodyElement = document.body;
}
function updateBoundState (state, bound) {
if (!bound) return state;
bound = String(bound);
var boundTop = !!~bound.indexOf('top');
var boundRight = !!~bound.indexOf('right');
var boundBottom = !!~bound.indexOf('bottom');
var boundLeft = !!~bound.indexOf('left');
var boundAll = !!~bound.indexOf('all') ||
!(boundTop || boundRight || boundBottom || boundLeft);
var boundBox = !~bound.indexOf('point');
state.boundTop = boundAll || boundTop;
state.boundRight = boundAll || boundRight;
state.boundBottom = boundAll || boundBottom;
state.boundLeft = boundAll || boundLeft;
state.boundBox = boundBox;
return state;
};
function createUIEvent(draggable) {
return {
position: {
top: draggable.state.offsetTop,
left: draggable.state.offsetLeft
}
};
}
function canDragY(draggable) {
return draggable.props.axis === 'both' ||
draggable.props.axis === 'y';
}
function canDragX(draggable) {
return draggable.props.axis === 'both' ||
draggable.props.axis === 'x';
}
function isFunction(func) {
return typeof func === 'function' || Object.prototype.toString.call(func) === '[object Function]'
}
// @credits https://gist.github.com/rogozhnikoff/a43cfed27c41e4e68cdc
function findInArray(array, callback) {
for (var i = 0, length = array.length, element = null; i < length, element = array[i]; i++) {
if (callback.apply(callback, [element, i, array])) return element;
}
}
function matchesSelector(el, selector) {
var method = findInArray([
'matches',
'webkitMatchesSelector',
'mozMatchesSelector',
'msMatchesSelector',
'oMatchesSelector'
], function(method){
return isFunction(el[method]);
});
return el[method].call(el, selector);
}
// @credits: http://stackoverflow.com/questions/4817029/whats-the-best-way-to-detect-a-touch-screen-device-using-javascript/4819886#4819886
var isTouchDevice = 'ontouchstart' in root // works on most browsers
|| 'onmsgesturechange' in root; // works on ie10 on ms surface
// look ::handleDragStart
//function isMultiTouch(e) {
// return e.touches && Array.isArray(e.touches) && e.touches.length > 1
//}
/**
* simple abstraction for dragging events names
* */
var dragEventFor = (function() {
var eventsFor = {
touch: {
start: 'touchstart',
move: 'touchmove',
end: 'touchend'
},
mouse: {
start: 'mousedown',
move: 'mousemove',
end: 'mouseup'
},
pointer: {
start: 'pointerdown',
move: 'pointermove',
end: 'pointerup'
}
};
if ('onpointerdown' in window) {
return eventsFor.pointer;
} else {
return eventsFor[isTouchDevice ? 'touch' : 'mouse'];
}
})();
/**
* get {clientX, clientY} positions of control
* */
function getControlPosition(e) {
var position = (e.touches && e.touches[0]) || e;
return {
clientX: position.clientX,
clientY: position.clientY
}
}
function addEvent(el, event, handler) {
if (!el) { return; }
if (el.attachEvent) {
el.attachEvent('on' + event, handler);
} else if (el.addEventListener) {
el.addEventListener(event, handler, true);
} else {
el['on' + event] = handler;
}
}
function removeEvent(el, event, handler) {
if (!el) { return; }
if (el.detachEvent) {
el.detachEvent('on' + event, handler);
} else if (el.removeEventListener) {
el.removeEventListener(event, handler, true);
} else {
el['on' + event] = null;
}
}
module.exports = React.createClass({
displayName: 'Draggable',
mixins: [React.addons.PureRenderMixin],
propTypes: {
/**
* `axis` determines which axis the draggable can move.
*
* 'both' allows movement horizontally and vertically.
* 'x' limits movement to horizontal axis.
* 'y' limits movement to vertical axis.
*
* Defaults to 'both'.
*/
axis: React.PropTypes.oneOf(['both', 'x', 'y']),
/**
* `handle` specifies a selector to be used as the handle that initiates drag.
*
* Example:
*
* ```jsx
* var App = React.createClass({
* render: function () {
* return (
* <Draggable handle=".handle">
* <div>
* <div className="handle">Click me to drag</div>
* <div>This is some other content</div>
* </div>
* </Draggable>
* );
* }
* });
* ```
*/
handle: React.PropTypes.string,
/**
* `cancel` specifies a selector to be used to prevent drag initialization.
*
* Example:
*
* ```jsx
* var App = React.createClass({
* render: function () {
* return(
* <Draggable cancel=".cancel">
* <div>
* <div className="cancel">You can't drag from here</div>
* <div>Dragging here works fine</div>
* </div>
* </Draggable>
* );
* }
* });
* ```
*/
cancel: React.PropTypes.string,
/**
* `bound` determines whether to bound the movement to the parent box.
*
* The property takes a list of space-separated strings. The Draggable
* is bounded by the nearest DOMNode.offsetParent. To set the offset
* parent, give it a position value other than 'static'.
*
* Optionally choose one or more bounds from:
* 'top' bounds movement to the top edge of the parent box.
* 'right' bounds movement to the right edge of the parent box.
* 'bottom' bounds movement to the bottom edge of the parent box.
* 'left' bounds movement to the left edge of the parent box.
* 'all' bounds movement to all edges (default if not specified).
*
* Optionally choose one anchor from:
* 'point' to constrain only the top-left corner.
* 'box' to constrain the entire box (default if not specified).
*
* You may use more than one bound, e.g. 'top left point'. Set to a
* falsy value to disable.
*
* Defaults to 'all box'.
*/
bound: React.PropTypes.string,
/**
* `grid` specifies the x and y that dragging should snap to.
*
* Example:
*
* ```jsx
* var App = React.createClass({
* render: function () {
* return (
* <Draggable grid={[25, 25]}>
* <div>I snap to a 25 x 25 grid</div>
* </Draggable>
* );
* }
* });
* ```
*/
grid: React.PropTypes.arrayOf(React.PropTypes.number),
/**
* `constrain` takes a function to constrain the dragging.
*
* Example:
*
* ```jsx
* function constrain (snap) {
* function constrainOffset (offset, prev) {
* var delta = offset - prev;
* if (Math.abs(delta) >= snap) {
* return prev + (delta < 0 ? -snap : snap);
* }
* return prev;
* }
* return function (pos) {
* return {
* top: constrainOffset(pos.top, pos.prevTop),
* left: constrainOffset(pos.left, pos.prevLeft)
* };
* };
* }
* var App = React.createClass({
* render: function () {
* return (
* <Draggable constrain={constrain}>
* <div>I snap to a 25 x 25 grid</div>
* </Draggable>
* );
* }
* });
* ```
*/
constrain: React.PropTypes.func,
/**
* `start` specifies the x and y that the dragged item should start at
*
* Example:
*
* ```jsx
* var App = React.createClass({
* render: function () {
* return (
* <Draggable start={{x: 25, y: 25}}>
* <div>I start with left: 25px; top: 25px;</div>
* </Draggable>
* );
* }
* });
* ```
*/
start: React.PropTypes.object,
/**
* `zIndex` specifies the zIndex to use while dragging.
*
* Example:
*
* ```jsx
* var App = React.createClass({
* render: function () {
* return (
* <Draggable zIndex={100}>
* <div>I have a zIndex</div>
* </Draggable>
* );
* }
* });
* ```
*/
zIndex: React.PropTypes.number,
/**
* `useChild` determines whether to use the first child as root.
*
* If false, a div is created. This option is required if any children
* have a ref.
*
* Defaults to true.
*/
useChild: React.PropTypes.bool,
/**
* Called when dragging starts.
*
* Example:
*
* ```js
* function (event, ui) {}
* ```
*
* `event` is the Event that was triggered.
* `ui` is an object:
*
* ```js
* {
* position: {top: 0, left: 0}
* }
* ```
*/
onStart: React.PropTypes.func,
/**
* Called while dragging.
*
* Example:
*
* ```js
* function (event, ui) {}
* ```
*
* `event` is the Event that was triggered.
* `ui` is an object:
*
* ```js
* {
* position: {top: 0, left: 0}
* }
* ```
*/
onDrag: React.PropTypes.func,
/**
* Called when dragging stops.
*
* Example:
*
* ```js
* function (event, ui) {}
* ```
*
* `event` is the Event that was triggered.
* `ui` is an object:
*
* ```js
* {
* position: {top: 0, left: 0}
* }
* ```
*/
onStop: React.PropTypes.func,
/**
* A workaround option which can be passed if onMouseDown needs to be accessed, since it'll always be blocked (due to that there's internal use of onMouseDown)
*
*/
onMouseDown: React.PropTypes.func
},
getDefaultProps: function () {
return {
axis: 'both',
bound: null,
handle: null,
cancel: null,
grid: null,
start: {},
zIndex: NaN,
useChild: true,
onStart: emptyFunction,
onDrag: emptyFunction,
onStop: emptyFunction,
onMouseDown: emptyFunction
};
},
getInitialState: function () {
var state = {
// Whether or not currently dragging
dragging: false,
// Pointer offset on screen
clientX: 0, clientY: 0,
// DOMNode offset relative to parent
offsetLeft: this.props.start.x || 0, offsetTop: this.props.start.y || 0
};
updateBoundState(state, this.props.bound);
return state;
},
componentWillReceiveProps: function (nextProps) {
var state = updateBoundState({}, nextProps.bound);
if (nextProps.start) {
if (nextProps.start.x != null) {
state.offsetLeft = nextProps.start.x || 0;
}
if (nextProps.start.y != null) {
state.offsetTop = nextProps.start.y || 0;
}
}
this.setState(state);
},
componentWillUnmount: function() {
// Remove any leftover event handlers
removeEvent(root, dragEventFor['move'], this.handleDrag);
removeEvent(root, dragEventFor['end'], this.handleDragEnd);
},
handleDragStart: function (e) {
// todo: write right implementation to prevent multitouch drag
// prevent multi-touch events
// if (isMultiTouch(e)) {
// this.handleDragEnd.apply(e, arguments);
// return
// }
// Make it possible to attach event handlers on top of this one
this.props.onMouseDown(e);
// Short circuit if handle or cancel prop was provided and selector doesn't match
if ((this.props.handle && !matchesSelector(e.target, this.props.handle)) ||
(this.props.cancel && matchesSelector(e.target, this.props.cancel))) {
return;
}
var dragPoint = getControlPosition(e);
// Initiate dragging
this.setState({
dragging: true,
clientX: dragPoint.clientX,
clientY: dragPoint.clientY
});
// Call event handler
this.props.onStart(e, createUIEvent(this));
// Add event handlers
addEvent(root, dragEventFor['move'], this.handleDrag);
addEvent(root, dragEventFor['end'], this.handleDragEnd);
// Add dragging class to body element
if (bodyElement) bodyElement.className += ' react-draggable-dragging';
},
handleDragEnd: function (e) {
// Short circuit if not currently dragging
if (!this.state.dragging) {
return;
}
// Turn off dragging
this.setState({
dragging: false
});
// Call event handler
this.props.onStop(e, createUIEvent(this));
// Remove event handlers
removeEvent(root, dragEventFor['move'], this.handleDrag);
removeEvent(root, dragEventFor['end'], this.handleDragEnd);
// Remove dragging class from body element
if (bodyElement) {
var className = bodyElement.className;
bodyElement.className =
className.replace(/(?:^|\s+)react-draggable-dragging\b/, ' ');
}
},
handleDrag: function (e) {
var dragPoint = getControlPosition(e);
var offsetLeft = this._toPixels(this.state.offsetLeft);
var offsetTop = this._toPixels(this.state.offsetTop);
var state = {
offsetLeft: offsetLeft,
offsetTop: offsetTop
};
// Get parent DOM node
var node = this.getDOMNode();
var offsetParent = node.offsetParent;
var offset, boundingValue;
if (canDragX(this)) {
// Calculate updated position
offset = offsetLeft + dragPoint.clientX - this.state.clientX;
// Bound movement to parent box
if (this.state.boundLeft) {
boundingValue = state.offsetLeft - node.offsetLeft;
if (offset < boundingValue) {
offset = boundingValue;
}
}
if (this.state.boundRight) {
boundingValue += offsetParent.clientWidth;
if (this.state.boundBox) {
boundingValue -= node.offsetWidth;
}
if (offset > boundingValue) {
offset = boundingValue;
}
}
// Update left
state.offsetLeft = offset;
}
if (canDragY(this)) {
// Calculate updated position
offset = offsetTop + dragPoint.clientY - this.state.clientY;
// Bound movement to parent box
if (this.state.boundTop) {
boundingValue = state.offsetTop - node.offsetTop;
if (offset < boundingValue) {
offset = boundingValue;
}
}
if (this.state.boundBottom) {
boundingValue += offsetParent.clientHeight;
if (this.state.boundBox) {
boundingValue -= node.offsetHeight;
}
if (offset > boundingValue) {
offset = boundingValue;
}
}
// Update top
state.offsetTop = offset;
}
var constrain = this.props.constrain;
var grid = this.props.grid;
// Backwards-compatibility for snap to grid
if (!constrain && Array.isArray(grid)) {
var constrainOffset = function (offset, prev, snap) {
var delta = offset - prev;
if (Math.abs(delta) >= snap) {
return prev + parseInt(delta / snap, 10) * snap;
}
return prev;
};
constrain = function (pos) {
return {
left: constrainOffset(pos.left, pos.prevLeft, grid[0]),
top: constrainOffset(pos.top, pos.prevTop, grid[1])
};
};
}
// Constrain if function has been provided
var positions;
if (constrain) {
// Constrain positions
positions = constrain({
prevLeft: this.state.offsetLeft,
prevTop: this.state.offsetTop,
left: state.offsetLeft,
top: state.offsetTop
});
if (positions) {
// Update left
if ('left' in positions && !isNaN(positions.left)) {
state.offsetLeft = positions.left;
}
// Update top
if ('top' in positions && !isNaN(positions.top)) {
state.offsetTop = positions.top;
}
}
}
// Save new state
state.clientX = this.state.clientX + (state.offsetLeft - offsetLeft);
state.clientY = this.state.clientY + (state.offsetTop - offsetTop);
this.setState(state);
// Call event handler
this.props.onDrag(e, createUIEvent(this));
},
onTouchStart: function (e) {
e.preventDefault(); // prevent for scroll
return this.handleDragStart.apply(this, arguments);
},
render: function () {
var style = {
top: this.state.offsetTop,
left: this.state.offsetLeft
};
// Set zIndex if currently dragging and prop has been provided
if (this.state.dragging && !isNaN(this.props.zIndex)) {
style.zIndex = this.props.zIndex;
}
var props = {
style: style,
className: 'react-draggable',
onMouseDown: this.handleDragStart,
onTouchStart: this.onTouchStart,
onMouseUp: this.handleDragEnd,
onTouchEnd: this.handleDragEnd
};
// Reuse the child provided
// This makes it flexible to use whatever element is wanted (div, ul, etc)
if (this.props.useChild) {
return React.addons.cloneWithProps(React.Children.only(this.props.children), props);
}
return React.DOM.div(props, this.props.children);
},
_toPixels: function (value) {
// Support percentages
if (typeof value == 'string' && value.slice(-1) == '%') {
return parseInt((+value.replace('%', '') / 100) *
this.getDOMNode().offsetParent.clientWidth, 10) || 0;
}
// Invalid values become zero
var i = parseInt(value, 10);
if (isNaN(i) || !isFinite(i)) return 0;
return i;
}
});