UNPKG

react-reorder

Version:

Drag & drop, touch enabled, reorderable / sortable list, React component

850 lines (675 loc) 26.2 kB
'use strict'; (function () { var CONSTANTS = { HOLD_THRESHOLD: 8, SCROLL_INTERVAL: 1000 / 60, SCROLL_AREA_MAX: 50, SCROLL_SPEED: 20 }; var downPos = null; var mouseOffset = null; var mouseDown = null; function createOffsetStyles (event, props) { var top = (!props.lock || props.lock === 'horizontal') ? mouseOffset.clientY - mouseDown.clientY : 0; var left = (!props.lock || props.lock === 'vertical') ? mouseOffset.clientX - mouseDown.clientX : 0; return 'translate(' + left + 'px,' + top + 'px)'; } function getScrollOffsetX (rect, node) { var positionInScrollArea; var scrollLeft = node.scrollLeft; var scrollWidth = node.scrollWidth; var scrollAreaX = Math.min(rect.width / 3, CONSTANTS.SCROLL_AREA_MAX); if (scrollLeft > 0 && mouseOffset.clientX <= rect.left + scrollAreaX) { positionInScrollArea = Math.min(Math.abs(rect.left + scrollAreaX - mouseOffset.clientX), scrollAreaX); return -positionInScrollArea / scrollAreaX * CONSTANTS.SCROLL_SPEED; } if (scrollLeft < scrollWidth - rect.width && mouseOffset.clientX >= rect.right - scrollAreaX) { positionInScrollArea = Math.min(Math.abs(rect.right - scrollAreaX - mouseOffset.clientX), scrollAreaX); return positionInScrollArea / scrollAreaX * CONSTANTS.SCROLL_SPEED; } return 0; } function getScrollOffsetY (rect, node) { var positionInScrollArea; var scrollTop = node.scrollTop; var scrollHeight = node.scrollHeight; var scrollAreaY = Math.min(rect.height / 3, CONSTANTS.SCROLL_AREA_MAX); if (scrollTop > 0 && mouseOffset.clientY <= rect.top + scrollAreaY) { positionInScrollArea = Math.min(Math.abs(rect.top + scrollAreaY - mouseOffset.clientY), scrollAreaY); return -positionInScrollArea / scrollAreaY * CONSTANTS.SCROLL_SPEED; } if (scrollTop < scrollHeight - rect.height && mouseOffset.clientY >= rect.bottom - scrollAreaY) { positionInScrollArea = Math.min(Math.abs(rect.bottom - scrollAreaY - mouseOffset.clientY), scrollAreaY); return positionInScrollArea / scrollAreaY * CONSTANTS.SCROLL_SPEED; } return 0; } function scrollParentsX (node) { var parent = node.parentNode; while (parent && parent !== document) { var rect = parent.getBoundingClientRect(); var scrollOffsetX = getScrollOffsetX(rect, parent); if (!scrollOffsetX) { scrollParentsX(parent); } else if (scrollOffsetX) { parent.scrollLeft = parent.scrollLeft + scrollOffsetX; return; } parent = parent.parentNode; } } function scrollParentsY (node) { var parent = node.parentNode; while (parent && parent !== document) { var rect = parent.getBoundingClientRect(); var scrollOffsetY = getScrollOffsetY(rect, parent); if (!scrollOffsetY) { scrollParentsX(parent); } else if (scrollOffsetY) { parent.scrollTop = parent.scrollTop + scrollOffsetY; return; } parent = parent.parentNode; } } function Store () { var activeGroup = null; var draggedId = null; var placedId = null; var draggedElement = null; var scrollInterval = null; var target = null; var draggedStyle = null; var draggedIndex = -1; var placedIndex = -1; var reorderComponents = {}; var reorderGroups = {}; function autoScroll () { if (target && target.props.autoScroll && target.rootNode) { var rect = target.rootNode.getBoundingClientRect(); if (target.props.lock !== 'horizontal') { var scrollOffsetX = getScrollOffsetX(rect, target.rootNode); if (target.props.autoScrollParents && !scrollOffsetX) { scrollParentsX(target.rootNode); } else if (scrollOffsetX) { target.rootNode.scrollLeft = target.rootNode.scrollLeft + scrollOffsetX; } } if (target.props.lock !== 'vertical') { var scrollOffsetY = getScrollOffsetY(rect, target.rootNode); if (target.props.autoScrollParents && !scrollOffsetY) { scrollParentsY(target.rootNode); } else if (scrollOffsetY) { target.rootNode.scrollTop = target.rootNode.scrollTop + scrollOffsetY; } } } } function getState () { return { draggedId: draggedId, placedId: placedId, activeGroup: activeGroup, draggedStyle: draggedStyle, draggedIndex: draggedIndex, placedIndex: placedIndex, draggedElement: draggedElement }; } function trigger (clear) { var state = getState(); if (clear) { for (var i = 0; i < clear.length; i += 1) { state[clear[i]] = null; } } reorderComponents[draggedId].setDragState(state); } function triggerGroup (clear) { var state = getState(); if (clear) { for (var i = 0; i < clear.length; i += 1) { state[clear[i]] = null; } } for (var reorderId in reorderGroups[activeGroup]) { reorderGroups[activeGroup][reorderId].setDragState(state); } } function validateComponentIdAndGroup (reorderId, reorderGroup) { if (typeof reorderId !== 'string') { throw new Error('Expected reorderId to be a string. Instead got ' + (typeof reorderId)); } if (typeof reorderGroup !== 'undefined' && typeof reorderGroup !== 'string') { throw new Error('Expected reorderGroup to be a string. Instead got ' + (typeof reorderGroup)); } } function registerReorderComponent (component) { var reorderId = component.props.reorderId; var reorderGroup = component.props.reorderGroup; validateComponentIdAndGroup(reorderId, reorderGroup); if (typeof reorderGroup !== 'undefined') { if ((reorderGroup in reorderGroups) && (reorderId in reorderGroups[reorderGroup])) { throw new Error('Duplicate reorderId: ' + reorderId + ' in reorderGroup: ' + reorderGroup); } reorderGroups[reorderGroup] = reorderGroups[reorderGroup] || {}; reorderGroups[reorderGroup][reorderId] = component; } else { if (reorderId in reorderComponents) { throw new Error('Duplicate reorderId: ' + reorderId); } reorderComponents[reorderId] = component; } } function unregisterReorderComponent (component) { var reorderId = component.props.reorderId; var reorderGroup = component.props.reorderGroup; validateComponentIdAndGroup(reorderId, reorderGroup); if (typeof reorderGroup !== 'undefined') { if (!(reorderGroup in reorderGroups)) { throw new Error('Unknown reorderGroup: ' + reorderGroup); } if ((reorderGroup in reorderGroups) && !(reorderId in reorderGroups[reorderGroup])) { throw new Error('Unknown reorderId: ' + reorderId + ' in reorderGroup: ' + reorderGroup); } delete reorderGroups[reorderGroup][reorderId]; } else { if (!(reorderId in reorderComponents)) { throw new Error('Unknown reorderId: ' + reorderId); } delete reorderComponents[reorderId]; } } function startDrag (reorderId, reorderGroup, index, element, component) { target = component; clearInterval(scrollInterval); scrollInterval = setInterval(autoScroll, CONSTANTS.SCROLL_INTERVAL); validateComponentIdAndGroup(reorderId, reorderGroup); draggedIndex = index; placedIndex = index; draggedStyle = null; draggedElement = element; draggedId = reorderId; placedId = reorderId; activeGroup = null; if (typeof reorderGroup !== 'undefined') { activeGroup = reorderGroup; triggerGroup(); } else if (draggedId !== null && reorderId === draggedId) { trigger(); } } function stopDrag (reorderId, reorderGroup) { target = null; clearInterval(scrollInterval); validateComponentIdAndGroup(reorderId, reorderGroup); if (activeGroup !== null) { if (reorderGroup === activeGroup) { draggedIndex = -1; placedIndex = -1; draggedStyle = null; draggedElement = null; // These need to be cleared after trigger to allow state updates to these components triggerGroup(['activeGroup']); draggedId = null; placedId = null; activeGroup = null; } } else if (draggedId !== null && reorderId === draggedId) { draggedIndex = -1; placedIndex = -1; draggedStyle = null; draggedElement = null; // These need to be cleared after trigger to allow state updates to these components trigger(['activeGroup']); draggedId = null; placedId = null; activeGroup = null; } } function setPlacedIndex (reorderId, reorderGroup, index, component) { target = component; validateComponentIdAndGroup(reorderId, reorderGroup); if (typeof reorderGroup !== 'undefined') { if (reorderGroup === activeGroup) { placedId = reorderId; placedIndex = index; triggerGroup(); } } else if (draggedId !== null && reorderId === draggedId) { placedIndex = index; trigger(); } } function setDraggedStyle (reorderId, reorderGroup, style) { validateComponentIdAndGroup(reorderId, reorderGroup); if (typeof reorderGroup !== 'undefined') { if (reorderGroup === activeGroup) { draggedStyle = style; triggerGroup(); } } else if (draggedId !== null && reorderId === draggedId) { draggedStyle = style; trigger(); } } this.getState = getState; this.registerReorderComponent = registerReorderComponent; this.unregisterReorderComponent = unregisterReorderComponent; this.startDrag = startDrag; this.stopDrag = stopDrag; this.setPlacedIndex = setPlacedIndex; this.setDraggedStyle = setDraggedStyle; } var store = new Store(); function reorder (list, previousIndex, nextIndex) { var copy = [].concat(list); var item = copy.splice(previousIndex, 1)[0]; copy.splice(nextIndex, 0, item); return copy; } function reorderImmutable (list, previousIndex, nextIndex) { var item = list.get(previousIndex); return list.delete(previousIndex).splice(nextIndex, 0, item); } function reorderFromTo (lists, previousIndex, nextIndex) { var previousList = [].concat(lists.from); var nextList = [].concat(lists.to); var item = previousList.splice(previousIndex, 1)[0]; nextList.splice(nextIndex, 0, item); return { from: previousList, to: nextList }; } function reorderFromToImmutable (lists, previousIndex, nextIndex) { var item = lists.from.get(previousIndex); return { from: lists.from.delete(previousIndex), to: lists.to.splice(nextIndex, 0, item) }; } function withReorderMethods (Reorder) { Reorder.reorder = reorder; Reorder.reorderImmutable = reorderImmutable; Reorder.reorderFromTo = reorderFromTo; Reorder.reorderFromToImmutable = reorderFromToImmutable; return Reorder; } function assign () { var args = Array.prototype.slice.call(arguments); if (!args.length) { return undefined; } if (args.length === 1) { return args[0]; } var obj = args.shift(); while (args.length) { var arg = args.shift(); for (var key in arg) { obj[key] = arg[key]; } } return obj; } function getReorderComponent (React, ReactDOM, createReactClass, PropTypes) { var Reorder = createReactClass({ displayName: 'Reorder', getInitialState: function () { return store.getState(); }, isDragging: function () { return this.state.draggedIndex >= 0; }, isPlacing: function () { return this.state.placedIndex >= 0; }, isDraggingFrom: function () { return this.props.reorderId === this.state.draggedId; }, isPlacingTo: function () { return this.props.reorderId === this.state.placedId; }, isInvolvedInDragging: function () { return this.props.reorderId === this.state.draggedId || this.props.reorderGroup === this.state.activeGroup; }, preventContextMenu: function (event) { if (downPos && this.props.disableContextMenus) { event.preventDefault(); } }, preventNativeScrolling: function (event) { event.preventDefault(); }, persistEvent: function (event) { if (typeof event.persist === 'function') { event.persist(); } }, copyTouchKeys: function (event) { if (event.touches && event.touches[0]) { this.persistEvent(event); event.clientX = event.touches[0].clientX; event.clientY = event.touches[0].clientY; } }, xCollision: function (event, rect) { return event.clientX >= rect.left && event.clientX <= rect.right; }, yCollision: function (event, rect) { return event.clientY >= rect.top && event.clientY <= rect.bottom; }, findCollisionIndex: function (event, listElements) { for (var i = 0; i < listElements.length; i += 1) { if (!listElements[i].getAttribute('data-placeholder') && !listElements[i].getAttribute('data-dragged')) { var rect = listElements[i].getBoundingClientRect(); switch (this.props.lock) { case 'horizontal': if (this.yCollision(event, rect)) { return i; } break; case 'vertical': if (this.xCollision(event, rect)) { return i; } break; default: if (this.yCollision(event, rect) && this.xCollision(event, rect)) { return i; } break; } } } return -1; }, collidesWithElement: function (event, element) { var rect = element.getBoundingClientRect(); return this.yCollision(event, rect) && this.xCollision(event, rect); }, getHoldTime: function (event) { if (event.touches && typeof this.props.touchHoldTime !== 'undefined') { return parseInt(this.props.touchHoldTime, 10) || 0; } else if (typeof this.props.mouseHoldTime !== 'undefined') { return parseInt(this.props.mouseHoldTime, 10) || 0; } return parseInt(this.props.holdTime, 10) || 0; }, startDrag: function (event, target, index) { if (!this.moved) { var rect = target.getBoundingClientRect(); var draggedStyle = { position: 'fixed', top: rect.top, left: rect.left, width: rect.width, height: rect.height }; store.startDrag(this.props.reorderId, this.props.reorderGroup, index, this.props.children[index], this); store.setDraggedStyle(this.props.reorderId, this.props.reorderGroup, draggedStyle); mouseOffset = { clientX: event.clientX, clientY: event.clientY }; mouseDown = { clientX: event.clientX, clientY: event.clientY }; } }, // Begin dragging index, set initial drag style, set placeholder position, calculate mouse offset onItemDown: function (callback, index, event) { if (typeof callback === 'function') { callback(event); } if (event.button === 2 || this.props.disabled) { return; } this.copyTouchKeys(event); this.moved = false; downPos = { clientX: event.clientX, clientY: event.clientY }; var holdTime = this.getHoldTime(event); var target = event.currentTarget; if (holdTime) { this.persistEvent(event); this.holdTimeout = setTimeout(this.startDrag.bind(this, event, target, index), holdTime); } else { this.startDrag(event, target, index); } }, // Stop dragging - reset style & draggedIndex, handle reorder onWindowUp: function (event) { clearTimeout(this.holdTimeout); if (this.isDragging() && this.isDraggingFrom()) { var fromIndex = this.state.draggedIndex; var toIndex = this.state.placedIndex; store.stopDrag(this.props.reorderId, this.props.reorderGroup); if ( fromIndex >= 0 && (fromIndex !== toIndex || this.state.draggedId !== this.state.placedId) && typeof this.props.onReorder === 'function' ) { this.props.onReorder( event, fromIndex, toIndex - (this.state.draggedId === this.state.placedId && fromIndex < toIndex ? 1 : 0), this.state.draggedId, this.state.placedId ); } } downPos = null; mouseOffset = null; mouseDown = null; }, // Update dragged position & placeholder index, invalidate drag if moved onWindowMove: function (event) { this.copyTouchKeys(event); if ( downPos && ( Math.abs(event.clientX - downPos.clientX) >= CONSTANTS.HOLD_THRESHOLD || Math.abs(event.clientY - downPos.clientY) >= CONSTANTS.HOLD_THRESHOLD ) ) { this.moved = true; } if (this.isDragging() && this.isInvolvedInDragging()) { this.preventNativeScrolling(event); var element = this.rootNode; if (this.collidesWithElement(event, element)) { var children = element.childNodes; var collisionIndex = this.findCollisionIndex(event, children); if ( collisionIndex <= this.props.children.length && collisionIndex >= 0 ) { store.setPlacedIndex(this.props.reorderId, this.props.reorderGroup, collisionIndex, this); } else if ( typeof this.props.reorderGroup !== 'undefined' && // Is part of a group ( (!this.props.children || !this.props.children.length) || // If all items removed (this.isDraggingFrom() && this.props.children.length === 1) // If dragging back to a now empty list ) ) { store.setPlacedIndex(this.props.reorderId, this.props.reorderGroup, 0, this); } } this.state.draggedStyle.transform = createOffsetStyles(event, this.props); store.setDraggedStyle(this.props.reorderId, this.props.reorderGroup, this.state.draggedStyle); mouseOffset = { clientX: event.clientX, clientY: event.clientY }; } }, setDragState: function (state) { var isPartOfGroup = this.props.reorderGroup; var isGroupDragged = state.activeGroup; var storedActiveGroup = this.state.activeGroup; var wasGroupDragged = !isGroupDragged && storedActiveGroup; var isActiveGroup = isPartOfGroup && isGroupDragged && state.activeGroup === this.props.reorderGroup; var isDragged = this.props.reorderId === state.draggedId; var isPlaced = this.props.reorderId === state.placedId; var wasPlaced = this.props.reorderId === this.state.placedId; // This check is like a shouldComponentUpdate but specific to our store state // Allowing prop changes to update the component if ( (!isGroupDragged && !isPartOfGroup && (isDragged || isPlaced)) || (isPartOfGroup && (!storedActiveGroup || wasGroupDragged)) || wasGroupDragged || (isActiveGroup && (isDragged || isPlaced || wasPlaced)) ) { this.setState(state); } }, // Add listeners and store root node componentDidMount: function () { store.registerReorderComponent(this); window.addEventListener('mouseup', this.onWindowUp, {passive: false}); window.addEventListener('touchend', this.onWindowUp, {passive: false}); window.addEventListener('mousemove', this.onWindowMove, {passive: false}); window.addEventListener('touchmove', this.onWindowMove, {passive: false}); window.addEventListener('contextmenu', this.preventContextMenu, {passive: false}); this.storeRootNode(); }, // Remove listeners componentWillUnmount: function () { store.unregisterReorderComponent(this); clearTimeout(this.holdTimeout); window.removeEventListener('mouseup', this.onWindowUp); window.removeEventListener('touchend', this.onWindowUp); window.removeEventListener('mousemove', this.onWindowMove); window.removeEventListener('touchmove', this.onWindowMove); window.removeEventListener('contextmenu', this.preventContextMenu); }, storeRootNode: function () { var element = ReactDOM.findDOMNode(this); this.rootNode = element; if (typeof this.props.getRef === 'function') { this.props.getRef(element); } }, render: function () { var children = this.props.children && this.props.children.map(function (child, index) { var isDragged = this.isDragging() && this.isDraggingFrom() && index === this.state.draggedIndex; var draggedStyle = isDragged ? assign({}, child.props.style, this.state.draggedStyle) : child.props.style; var draggedClass = [ child.props.className || '', (isDragged ? this.props.draggedClassName : '') ].join(' '); return React.cloneElement( isDragged ? this.state.draggedElement : child, { style: draggedStyle, className: draggedClass, onMouseDown: this.onItemDown.bind(this, child.props.onMouseDown, index), onTouchStart: this.onItemDown.bind(this, child.props.onTouchStart, index), 'data-dragged': isDragged ? true : null } ); }.bind(this)); var placeholderElement = this.props.placeholder || this.state.draggedElement; if (this.isPlacing() && this.isPlacingTo() && placeholderElement) { var placeholder = React.cloneElement( placeholderElement, { key: 'react-reorder-placeholder', className: [placeholderElement.props.className || '', this.props.placeholderClassName].join(' '), 'data-placeholder': true } ); children.splice(this.state.placedIndex, 0, placeholder); } return React.createElement( this.props.component, { className: this.props.className, id: this.props.id, style: this.props.style, onClick: this.props.onClick }, children ); } }); Reorder.propTypes = { component: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), getRef: PropTypes.func, reorderId: PropTypes.string, reorderGroup: PropTypes.string, placeholderClassName: PropTypes.string, draggedClassName: PropTypes.string, lock: PropTypes.string, holdTime: PropTypes.number, touchHoldTime: PropTypes.number, mouseHoldTime: PropTypes.number, onReorder: PropTypes.func, placeholder: PropTypes.element, autoScroll: PropTypes.bool, autoScrollParents: PropTypes.bool, disabled: PropTypes.bool, disableContextMenus: PropTypes.bool }; Reorder.defaultProps = { component: 'div', // getRef: function, // reorderId: id, // reorderGroup: group, placeholderClassName: 'placeholder', draggedClassName: 'dragged', // lock: direction, holdTime: 0, // touchHoldTime: 0, // mouseHoldTime: 0, // onReorder: function, // placeholder: react element autoScroll: true, autoScrollParents: true, disabled: false, disableContextMenus: true }; return Reorder; } /* istanbul ignore next */ // Export for commonjs / browserify if (typeof exports === 'object' && typeof module !== 'undefined') { var React = require('react'); // eslint-disable-line no-undef var ReactDOM = require('react-dom'); // eslint-disable-line no-undef var createReactClass = require('create-react-class'); // eslint-disable-line no-undef var PropTypes = require('prop-types'); // eslint-disable-line no-undef module.exports = withReorderMethods( // eslint-disable-line no-undef getReorderComponent(React, ReactDOM, createReactClass, PropTypes) ); // Export for amd / require } else if (typeof define === 'function' && define.amd) { // eslint-disable-line no-undef define( // eslint-disable-line no-undef ['react', 'react-dom', 'create-react-class', 'prop-types'], function (ReactAMD, ReactDOMAMD, createReactClassAMD, PropTypesAMD) { return withReorderMethods( getReorderComponent(ReactAMD, ReactDOMAMD, createReactClassAMD, PropTypesAMD) ); } ); // Export globally } else { var root; if (typeof window !== 'undefined') { root = window; } else if (typeof global !== 'undefined') { root = global; // eslint-disable-line no-undef } else if (typeof self !== 'undefined') { root = self; // eslint-disable-line no-undef } else { root = this; } root.Reorder = withReorderMethods( getReorderComponent(root.React, root.ReactDOM, root.createReactClass, root.PropTypes) ); } })();