UNPKG

muuri

Version:

Responsive, sortable, filterable and draggable grid layouts.

1,700 lines (1,455 loc) 166 kB
/** * Muuri v0.7.1 * https://github.com/haltu/muuri * Copyright (c) 2015-present, Haltu Oy * Released under the MIT license * https://github.com/haltu/muuri/blob/master/LICENSE.md * @license MIT * * Muuri Packer * Copyright (c) 2016-present, Niklas Rämö <inramo@gmail.com> * @license MIT * * Muuri Ticker / Muuri Emitter / Muuri Queue * Copyright (c) 2018-present, Niklas Rämö <inramo@gmail.com> * @license MIT */ (function (global, factory) { if (typeof exports === 'object' && typeof module !== 'undefined') { var Hammer; try { Hammer = require('hammerjs') } catch (e) {} module.exports = factory(Hammer); } else { global.Muuri = factory(global.Hammer); } }(this, (function (Hammer) { 'use strict'; var namespace = 'Muuri'; var gridInstances = {}; var eventSynchronize = 'synchronize'; var eventLayoutStart = 'layoutStart'; var eventLayoutEnd = 'layoutEnd'; var eventAdd = 'add'; var eventRemove = 'remove'; var eventShowStart = 'showStart'; var eventShowEnd = 'showEnd'; var eventHideStart = 'hideStart'; var eventHideEnd = 'hideEnd'; var eventFilter = 'filter'; var eventSort = 'sort'; var eventMove = 'move'; var eventSend = 'send'; var eventBeforeSend = 'beforeSend'; var eventReceive = 'receive'; var eventBeforeReceive = 'beforeReceive'; var eventDragInit = 'dragInit'; var eventDragStart = 'dragStart'; var eventDragMove = 'dragMove'; var eventDragScroll = 'dragScroll'; var eventDragEnd = 'dragEnd'; var eventDragReleaseStart = 'dragReleaseStart'; var eventDragReleaseEnd = 'dragReleaseEnd'; var eventDestroy = 'destroy'; /** * Event emitter constructor. * * @class */ function Emitter() { this._events = {}; this._queue = []; this._counter = 0; this._isDestroyed = false; } /** * Public prototype methods * ************************ */ /** * Bind an event listener. * * @public * @memberof Emitter.prototype * @param {String} event * @param {Function} listener * @returns {Emitter} */ Emitter.prototype.on = function(event, listener) { if (this._isDestroyed) return this; // Get listeners queue and create it if it does not exist. var listeners = this._events[event]; if (!listeners) listeners = this._events[event] = []; // Add the listener to the queue. listeners.push(listener); return this; }; /** * Bind an event listener that is triggered only once. * * @public * @memberof Emitter.prototype * @param {String} event * @param {Function} listener * @returns {Emitter} */ Emitter.prototype.once = function(event, listener) { if (this._isDestroyed) return this; var callback = function() { this.off(event, callback); listener.apply(null, arguments); }.bind(this); return this.on(event, callback); }; /** * Unbind all event listeners that match the provided listener function. * * @public * @memberof Emitter.prototype * @param {String} event * @param {Function} [listener] * @returns {Emitter} */ Emitter.prototype.off = function(event, listener) { if (this._isDestroyed) return this; // Get listeners and return immediately if none is found. var listeners = this._events[event]; if (!listeners || !listeners.length) return this; // If no specific listener is provided remove all listeners. if (!listener) { listeners.length = 0; return this; } // Remove all matching listeners. var i = listeners.length; while (i--) { if (listener === listeners[i]) listeners.splice(i, 1); } return this; }; /** * Emit all listeners in a specified event with the provided arguments. * * @public * @memberof Emitter.prototype * @param {String} event * @param {*} [arg1] * @param {*} [arg2] * @param {*} [arg3] * @returns {Emitter} */ Emitter.prototype.emit = function(event, arg1, arg2, arg3) { if (this._isDestroyed) return this; // Get event listeners and quit early if there's no listeners. var listeners = this._events[event]; if (!listeners || !listeners.length) return this; var queue = this._queue; var qLength = queue.length; var aLength = arguments.length - 1; var i; // Add the current listeners to the callback queue before we process them. // This is necessary to guarantee that all of the listeners are called in // correct order even if new event listeners are removed/added during // processing and/or events are emitted during processing. for (i = 0; i < listeners.length; i++) { queue.push(listeners[i]); } // Increment queue counter. This is needed for the scenarios where emit is // triggered while the queue is already processing. We need to keep track of // how many "queue processors" there are active so that we can safely reset // the queue in the end when the last queue processor is finished. ++this._counter; // Process the queue (the specific part of it for this emit). for (i = qLength, qLength = queue.length; i < qLength; i++) { // prettier-ignore aLength === 0 ? queue[i]() : aLength === 1 ? queue[i](arg1) : aLength === 2 ? queue[i](arg1, arg2) : queue[i](arg1, arg2, arg3); // Stop processing if the emitter is destroyed. if (this._isDestroyed) return this; } // Decrement queue process counter. --this._counter; // Reset the queue if there are no more queue processes running. if (!this._counter) queue.length = 0; return this; }; /** * Destroy emitter instance. Basically just removes all bound listeners. * * @public * @memberof Emitter.prototype * @returns {Emitter} */ Emitter.prototype.destroy = function() { if (this._isDestroyed) return this; var events = this._events; var event; // Flag as destroyed. this._isDestroyed = true; // Reset queue (if queue is currently processing this will also stop that). this._queue.length = this._counter = 0; // Remove all listeners. for (event in events) { if (events[event]) { events[event].length = 0; events[event] = undefined; } } return this; }; // Set up the default export values. var isTransformSupported = false; var transformStyle = 'transform'; var transformProp = 'transform'; // Find the supported transform prop and style names. var style = 'transform'; var styleCap = 'Transform'; ['', 'Webkit', 'Moz', 'O', 'ms'].forEach(function(prefix) { if (isTransformSupported) return; var propName = prefix ? prefix + styleCap : style; if (document.documentElement.style[propName] !== undefined) { prefix = prefix.toLowerCase(); transformStyle = prefix ? '-' + prefix + '-' + style : style; transformProp = propName; isTransformSupported = true; } }); var stylesCache = typeof WeakMap === 'function' ? new WeakMap() : null; /** * Returns the computed value of an element's style property as a string. * * @param {HTMLElement} element * @param {String} style * @returns {String} */ function getStyle(element, style) { var styles = stylesCache && stylesCache.get(element); if (!styles) { styles = window.getComputedStyle(element, null); stylesCache && stylesCache.set(element, styles); } return styles.getPropertyValue(style === 'transform' ? transformStyle : style); } var styleNameRegEx = /([A-Z])/g; /** * Transforms a camel case style property to kebab case style property. * * @param {String} string * @returns {String} */ function getStyleName(string) { return string.replace(styleNameRegEx, '-$1').toLowerCase(); } /** * Set inline styles to an element. * * @param {HTMLElement} element * @param {Object} styles */ function setStyles(element, styles) { for (var prop in styles) { element.style[prop === 'transform' ? transformProp : prop] = styles[prop]; } } /** * Item animation handler powered by Web Animations API. * * @class * @param {HTMLElement} element */ function ItemAnimate(element) { this._element = element; this._animation = null; this._callback = null; this._props = []; this._values = []; this._keyframes = []; this._options = {}; this._isDestroyed = false; this._onFinish = this._onFinish.bind(this); } /** * Public prototype methods * ************************ */ /** * Start instance's animation. Automatically stops current animation if it is * running. * * @public * @memberof ItemAnimate.prototype * @param {Object} propsFrom * @param {Object} propsTo * @param {Object} [options] * @param {Number} [options.duration=300] * @param {String} [options.easing='ease'] * @param {Function} [options.onFinish] */ ItemAnimate.prototype.start = function(propsFrom, propsTo, options) { if (this._isDestroyed) return; var animation = this._animation; var currentProps = this._props; var currentValues = this._values; var opts = options || 0; var cancelAnimation = false; // If we have an existing animation running, let's check if it needs to be // cancelled or if it can continue running. if (animation) { var propCount = 0; var propIndex; // Check if the requested animation target props and values match with the // current props and values. for (var propName in propsTo) { ++propCount; propIndex = currentProps.indexOf(propName); if (propIndex === -1 || propsTo[propName] !== currentValues[propIndex]) { cancelAnimation = true; break; } } // Check if the target props count matches current props count. This is // needed for the edge case scenario where target props contain the same // styles as current props, but the current props have some additional // props. if (!cancelAnimation && propCount !== currentProps.length) { cancelAnimation = true; } } // Cancel animation (if required). if (cancelAnimation) animation.cancel(); // Store animation callback. this._callback = typeof opts.onFinish === 'function' ? opts.onFinish : null; // If we have a running animation that does not need to be cancelled, let's // call it a day here and let it run. if (animation && !cancelAnimation) return; // Store target props and values to instance. currentProps.length = currentValues.length = 0; for (propName in propsTo) { currentProps.push(propName); currentValues.push(propsTo[propName]); } // Set up keyframes. var animKeyframes = this._keyframes; animKeyframes[0] = propsFrom; animKeyframes[1] = propsTo; // Set up options. var animOptions = this._options; animOptions.duration = opts.duration || 300; animOptions.easing = opts.easing || 'ease'; // Start the animation var element = this._element; animation = element.animate(animKeyframes, animOptions); animation.onfinish = this._onFinish; this._animation = animation; // Set the end styles. This makes sure that the element stays at the end // values after animation is finished. setStyles(element, propsTo); }; /** * Stop instance's current animation if running. * * @public * @memberof ItemAnimate.prototype * @param {Object} [styles] */ ItemAnimate.prototype.stop = function(styles) { if (this._isDestroyed || !this._animation) return; var element = this._element; var currentProps = this._props; var currentValues = this._values; var propName; var propValue; var i; // Calculate (if not provided) and set styles. if (!styles) { for (i = 0; i < currentProps.length; i++) { propName = currentProps[i]; propValue = getStyle(element, getStyleName(propName)); element.style[propName === 'transform' ? transformProp : propName] = propValue; } } else { setStyles(element, styles); } // Cancel animation. this._animation.cancel(); this._animation = this._callback = null; // Reset current props and values. currentProps.length = currentValues.length = 0; }; /** * Check if the item is being animated currently. * * @public * @memberof ItemAnimate.prototype * @return {Boolean} */ ItemAnimate.prototype.isAnimating = function() { return !!this._animation; }; /** * Destroy the instance and stop current animation if it is running. * * @public * @memberof ItemAnimate.prototype */ ItemAnimate.prototype.destroy = function() { if (this._isDestroyed) return; this.stop(); this._element = this._options = this._keyframes = null; this._isDestroyed = true; }; /** * Private prototype methods * ************************* */ /** * Animation end handler. * * @private * @memberof ItemAnimate.prototype */ ItemAnimate.prototype._onFinish = function() { var callback = this._callback; this._animation = this._callback = null; this._props.length = this._values.length = 0; callback && callback(); }; var raf = ( window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || rafFallback ).bind(window); function rafFallback(cb) { return window.setTimeout(cb, 16); } /** * A ticker system for handling DOM reads and writes in an efficient way. * Contains a read queue and a write queue that are processed on the next * animation frame when needed. * * @class */ function Ticker() { this._nextTick = null; this._queue = []; this._reads = {}; this._writes = {}; this._batch = []; this._batchReads = {}; this._batchWrites = {}; this._flush = this._flush.bind(this); } Ticker.prototype.add = function(id, readCallback, writeCallback, isImportant) { // First, let's check if an item has been added to the queues with the same id // and if so -> remove it. var currentIndex = this._queue.indexOf(id); if (currentIndex > -1) this._queue[currentIndex] = undefined; // Add all important callbacks to the beginning of the queue and other // callbacks to the end of the queue. isImportant ? this._queue.unshift(id) : this._queue.push(id); // Store callbacks. this._reads[id] = readCallback; this._writes[id] = writeCallback; // Finally, let's kick-start the next tick if it is not running yet. if (!this._nextTick) this._nextTick = raf(this._flush); }; Ticker.prototype.cancel = function(id) { var currentIndex = this._queue.indexOf(id); if (currentIndex > -1) { this._queue[currentIndex] = undefined; this._reads[id] = undefined; this._writes[id] = undefined; } }; Ticker.prototype._flush = function() { var queue = this._queue; var reads = this._reads; var writes = this._writes; var batch = this._batch; var batchReads = this._batchReads; var batchWrites = this._batchWrites; var length = queue.length; var id; var i; // Reset ticker. this._nextTick = null; // Setup queues and callback placeholders. for (i = 0; i < length; i++) { id = queue[i]; if (!id) continue; batch.push(id); batchReads[id] = reads[id]; reads[id] = undefined; batchWrites[id] = writes[id]; writes[id] = undefined; } // Reset queue. queue.length = 0; // Process read callbacks. for (i = 0; i < length; i++) { id = batch[i]; if (batchReads[id]) { batchReads[id](); batchReads[id] = undefined; } } // Process write callbacks. for (i = 0; i < length; i++) { id = batch[i]; if (batchWrites[id]) { batchWrites[id](); batchWrites[id] = undefined; } } // Reset batch. batch.length = 0; // Restart the ticker if needed. if (!this._nextTick && queue.length) { this._nextTick = raf(this._flush); } }; var ticker = new Ticker(); var layoutTick = 'layout'; var visibilityTick = 'visibility'; var moveTick = 'move'; var scrollTick = 'scroll'; function addLayoutTick(itemId, readCallback, writeCallback) { return ticker.add(itemId + layoutTick, readCallback, writeCallback); } function cancelLayoutTick(itemId) { return ticker.cancel(itemId + layoutTick); } function addVisibilityTick(itemId, readCallback, writeCallback) { return ticker.add(itemId + visibilityTick, readCallback, writeCallback); } function cancelVisibilityTick(itemId) { return ticker.cancel(itemId + visibilityTick); } function addMoveTick(itemId, readCallback, writeCallback) { return ticker.add(itemId + moveTick, readCallback, writeCallback, true); } function cancelMoveTick(itemId) { return ticker.cancel(itemId + moveTick); } function addScrollTick(itemId, readCallback, writeCallback) { return ticker.add(itemId + scrollTick, readCallback, writeCallback, true); } function cancelScrollTick(itemId) { return ticker.cancel(itemId + scrollTick); } var proto = Element.prototype; var matches = proto.matches || proto.matchesSelector || proto.webkitMatchesSelector || proto.mozMatchesSelector || proto.msMatchesSelector || proto.oMatchesSelector; /** * Check if element matches a CSS selector. * * @param {*} val * @returns {Boolean} */ function elementMatches(el, selector) { return matches.call(el, selector); } /** * Add class to an element. * * @param {HTMLElement} element * @param {String} className */ function addClassModern(element, className) { element.classList.add(className); } /** * Add class to an element (legacy version, for IE9 support). * * @param {HTMLElement} element * @param {String} className */ function addClassLegacy(element, className) { if (!elementMatches(element, '.' + className)) { element.className += ' ' + className; } } var addClass = ('classList' in Element.prototype ? addClassModern : addClassLegacy); /** * Normalize array index. Basically this function makes sure that the provided * array index is within the bounds of the provided array and also transforms * negative index to the matching positive index. * * @param {Array} array * @param {Number} index * @param {Boolean} isMigration */ function normalizeArrayIndex(array, index, isMigration) { var length = array.length; var maxIndex = Math.max(0, isMigration ? length : length - 1); return index > maxIndex ? maxIndex : index < 0 ? Math.max(maxIndex + index + 1, 0) : index; } /** * Move array item to another index. * * @param {Array} array * @param {Number} fromIndex * - Index (positive or negative) of the item that will be moved. * @param {Number} toIndex * - Index (positive or negative) where the item should be moved to. */ function arrayMove(array, fromIndex, toIndex) { // Make sure the array has two or more items. if (array.length < 2) return; // Normalize the indices. var from = normalizeArrayIndex(array, fromIndex); var to = normalizeArrayIndex(array, toIndex); // Add target item to the new position. if (from !== to) { array.splice(to, 0, array.splice(from, 1)[0]); } } /** * Swap array items. * * @param {Array} array * @param {Number} index * - Index (positive or negative) of the item that will be swapped. * @param {Number} withIndex * - Index (positive or negative) of the other item that will be swapped. */ function arraySwap(array, index, withIndex) { // Make sure the array has two or more items. if (array.length < 2) return; // Normalize the indices. var indexA = normalizeArrayIndex(array, index); var indexB = normalizeArrayIndex(array, withIndex); var temp; // Swap the items. if (indexA !== indexB) { temp = array[indexA]; array[indexA] = array[indexB]; array[indexB] = temp; } } var actionCancel = 'cancel'; var actionFinish = 'finish'; /** * Returns a function, that, as long as it continues to be invoked, will not * be triggered. The function will be called after it stops being called for * N milliseconds. The returned function accepts one argument which, when * being "finish", calls the debounce function immediately if it is currently * waiting to be called, and when being "cancel" cancels the currently queued * function call. * * @param {Function} fn * @param {Number} wait * @returns {Function} */ function debounce(fn, wait) { var timeout; if (wait > 0) { return function(action) { if (timeout !== undefined) { timeout = window.clearTimeout(timeout); if (action === actionFinish) fn(); } if (action !== actionCancel && action !== actionFinish) { timeout = window.setTimeout(function() { timeout = undefined; fn(); }, wait); } }; } return function(action) { if (action !== actionCancel) fn(); }; } /** * Returns true if element is transformed, false if not. In practice the * element's display value must be anything else than "none" or "inline" as * well as have a valid transform value applied in order to be counted as a * transformed element. * * Borrowed from Mezr (v0.6.1): * https://github.com/niklasramo/mezr/blob/0.6.1/mezr.js#L661 * * @param {HTMLElement} element * @returns {Boolean} */ function isTransformed(element) { var transform = getStyle(element, 'transform'); if (!transform || transform === 'none') return false; var display = getStyle(element, 'display'); if (display === 'inline' || display === 'none') return false; return true; } /** * Returns an absolute positioned element's containing block, which is * considered to be the closest ancestor element that the target element's * positioning is relative to. Disclaimer: this only works as intended for * absolute positioned elements. * * @param {HTMLElement} element * @param {Boolean} [includeSelf=false] * - When this is set to true the containing block checking is started from * the provided element. Otherwise the checking is started from the * provided element's parent element. * @returns {(Document|Element)} */ function getContainingBlock(element, includeSelf) { // As long as the containing block is an element, static and not // transformed, try to get the element's parent element and fallback to // document. https://github.com/niklasramo/mezr/blob/0.6.1/mezr.js#L339 var ret = (includeSelf ? element : element.parentElement) || document; while (ret && ret !== document && getStyle(ret, 'position') === 'static' && !isTransformed(ret)) { ret = ret.parentElement || document; } return ret; } /** * Returns the computed value of an element's style property transformed into * a float value. * * @param {HTMLElement} el * @param {String} style * @returns {Number} */ function getStyleAsFloat(el, style) { return parseFloat(getStyle(el, style)) || 0; } var offsetA = {}; var offsetB = {}; var offsetDiff = {}; /** * Returns the element's document offset, which in practice means the vertical * and horizontal distance between the element's northwest corner and the * document's northwest corner. Note that this function always returns the same * object so be sure to read the data from it instead using it as a reference. * * @param {(Document|Element|Window)} element * @param {Object} [offsetData] * - Optional data object where the offset data will be inserted to. If not * provided a new object will be created for the return data. * @returns {Object} */ function getOffset(element, offsetData) { var ret = offsetData || {}; var rect; // Set up return data. ret.left = 0; ret.top = 0; // Document's offsets are always 0. if (element === document) return ret; // Add viewport scroll left/top to the respective offsets. ret.left = window.pageXOffset || 0; ret.top = window.pageYOffset || 0; // Window's offsets are the viewport scroll left/top values. if (element.self === window.self) return ret; // Add element's client rects to the offsets. rect = element.getBoundingClientRect(); ret.left += rect.left; ret.top += rect.top; // Exclude element's borders from the offset. ret.left += getStyleAsFloat(element, 'border-left-width'); ret.top += getStyleAsFloat(element, 'border-top-width'); return ret; } /** * Calculate the offset difference two elements. * * @param {HTMLElement} elemA * @param {HTMLElement} elemB * @param {Boolean} [compareContainingBlocks=false] * - When this is set to true the containing blocks of the provided elements * will be used for calculating the difference. Otherwise the provided * elements will be compared directly. * @returns {Object} */ function getOffsetDiff(elemA, elemB, compareContainingBlocks) { offsetDiff.left = 0; offsetDiff.top = 0; // If elements are same let's return early. if (elemA === elemB) return offsetDiff; // Compare containing blocks if necessary. if (compareContainingBlocks) { elemA = getContainingBlock(elemA, true); elemB = getContainingBlock(elemB, true); // If containing blocks are identical, let's return early. if (elemA === elemB) return offsetDiff; } // Finally, let's calculate the offset diff. getOffset(elemA, offsetA); getOffset(elemB, offsetB); offsetDiff.left = offsetB.left - offsetA.left; offsetDiff.top = offsetB.top - offsetA.top; return offsetDiff; } var translateData = {}; /** * Returns the element's computed translateX and translateY values as a floats. * The returned object is always the same object and updated every time this * function is called. * * @param {HTMLElement} element * @returns {Object} */ function getTranslate(element) { translateData.x = 0; translateData.y = 0; var transform = getStyle(element, 'transform'); if (!transform) return translateData; var matrixData = transform.replace('matrix(', '').split(','); translateData.x = parseFloat(matrixData[4]) || 0; translateData.y = parseFloat(matrixData[5]) || 0; return translateData; } /** * Transform translateX and translateY value into CSS transform style * property's value. * * @param {Number} x * @param {Number} y * @returns {String} */ function getTranslateString(x, y) { return 'translateX(' + x + 'px) translateY(' + y + 'px)'; } var tempArray = []; /** * Insert an item or an array of items to array to a specified index. Mutates * the array. The index can be negative in which case the items will be added * to the end of the array. * * @param {Array} array * @param {*} items * @param {Number} [index=-1] */ function arrayInsert(array, items, index) { var startIndex = typeof index === 'number' ? index : -1; if (startIndex < 0) startIndex = array.length - startIndex + 1; array.splice.apply(array, tempArray.concat(startIndex, 0, items)); tempArray.length = 0; } var objectType = '[object Object]'; var toString = Object.prototype.toString; /** * Check if a value is a plain object. * * @param {*} val * @returns {Boolean} */ function isPlainObject(val) { return typeof val === 'object' && toString.call(val) === objectType; } /** * Remove class from an element. * * @param {HTMLElement} element * @param {String} className */ function removeClassModern(element, className) { element.classList.remove(className); } /** * Remove class from an element (legacy version, for IE9 support). * * @param {HTMLElement} element * @param {String} className */ function removeClassLegacy(element, className) { if (elementMatches(element, '.' + className)) { element.className = (' ' + element.className + ' ').replace(' ' + className + ' ', ' ').trim(); } } var removeClass = ('classList' in Element.prototype ? removeClassModern : removeClassLegacy); // To provide consistently correct dragging experience we need to know if // transformed elements leak fixed elements or not. var hasTransformLeak = checkTransformLeak(); // Drag start predicate states. var startPredicateInactive = 0; var startPredicatePending = 1; var startPredicateResolved = 2; var startPredicateRejected = 3; /** * Bind Hammer touch interaction to an item. * * @class * @param {Item} item */ function ItemDrag(item) { if (!Hammer) { throw new Error('[' + namespace + '] required dependency Hammer is not defined.'); } // If we don't have a valid transform leak test result yet, let's run the // test on first ItemDrag init. The test needs body element to be ready and // here we can be sure that it is ready. if (hasTransformLeak === null) { hasTransformLeak = checkTransformLeak(); } var drag = this; var element = item._element; var grid = item.getGrid(); var settings = grid._settings; var hammer; // Start predicate private data. var startPredicate = typeof settings.dragStartPredicate === 'function' ? settings.dragStartPredicate : ItemDrag.defaultStartPredicate; var startPredicateState = startPredicateInactive; var startPredicateResult; // Protected data. this._item = item; this._gridId = grid._id; this._hammer = hammer = new Hammer.Manager(element); this._isDestroyed = false; this._isMigrating = false; // Setup item's initial drag data. this._reset(); // Bind some methods that needs binding. this._onScroll = this._onScroll.bind(this); this._prepareMove = this._prepareMove.bind(this); this._applyMove = this._applyMove.bind(this); this._prepareScroll = this._prepareScroll.bind(this); this._applyScroll = this._applyScroll.bind(this); this._checkOverlap = this._checkOverlap.bind(this); // Create a private drag start resolver that can be used to resolve the drag // start predicate asynchronously. this._forceResolveStartPredicate = function(event) { if (!this._isDestroyed && startPredicateState === startPredicatePending) { startPredicateState = startPredicateResolved; this._onStart(event); } }; // Create debounce overlap checker function. this._checkOverlapDebounce = debounce(this._checkOverlap, settings.dragSortInterval); // Add drag recognizer to hammer. hammer.add( new Hammer.Pan({ event: 'drag', pointers: 1, threshold: 0, direction: Hammer.DIRECTION_ALL }) ); // Add drag init recognizer to hammer. hammer.add( new Hammer.Press({ event: 'draginit', pointers: 1, threshold: 1000, time: 0 }) ); // Configure the hammer instance. if (isPlainObject(settings.dragHammerSettings)) { hammer.set(settings.dragHammerSettings); } // Bind drag events. hammer .on('draginit dragstart dragmove', function(e) { // Let's activate drag start predicate state. if (startPredicateState === startPredicateInactive) { startPredicateState = startPredicatePending; } // If predicate is pending try to resolve it. if (startPredicateState === startPredicatePending) { startPredicateResult = startPredicate(drag._item, e); if (startPredicateResult === true) { startPredicateState = startPredicateResolved; drag._onStart(e); } else if (startPredicateResult === false) { startPredicateState = startPredicateRejected; } } // Otherwise if predicate is resolved and drag is active, move the item. else if (startPredicateState === startPredicateResolved && drag._isActive) { drag._onMove(e); } }) .on('dragend dragcancel draginitup', function(e) { // Check if the start predicate was resolved during drag. var isResolved = startPredicateState === startPredicateResolved; // Do final predicate check to allow user to unbind stuff for the current // drag procedure within the predicate callback. The return value of this // check will have no effect to the state of the predicate. startPredicate(drag._item, e); // Reset start predicate state. startPredicateState = startPredicateInactive; // If predicate is resolved and dragging is active, call the end handler. if (isResolved && drag._isActive) drag._onEnd(e); }); // Prevent native link/image dragging for the item and it's ancestors. element.addEventListener('dragstart', preventDefault, false); } /** * Public static methods * ********************* */ /** * Default drag start predicate handler that handles anchor elements * gracefully. The return value of this function defines if the drag is * started, rejected or pending. When true is returned the dragging is started * and when false is returned the dragging is rejected. If nothing is returned * the predicate will be called again on the next drag movement. * * @public * @memberof ItemDrag * @param {Item} item * @param {Object} event * @param {Object} [options] * - An optional options object which can be used to pass the predicate * it's options manually. By default the predicate retrieves the options * from the grid's settings. * @returns {Boolean} */ ItemDrag.defaultStartPredicate = function(item, event, options) { var drag = item._drag; var predicate = drag._startPredicateData || drag._setupStartPredicate(options); // Final event logic. At this stage return value does not matter anymore, // the predicate is either resolved or it's not and there's nothing to do // about it. Here we just reset data and if the item element is a link // we follow it (if there has only been slight movement). if (event.isFinal) { drag._finishStartPredicate(event); return; } // Find and store the handle element so we can check later on if the // cursor is within the handle. If we have a handle selector let's find // the corresponding element. Otherwise let's use the item element as the // handle. if (!predicate.handleElement) { predicate.handleElement = drag._getStartPredicateHandle(event); if (!predicate.handleElement) return false; } // If delay is defined let's keep track of the latest event and initiate // delay if it has not been done yet. if (predicate.delay) { predicate.event = event; if (!predicate.delayTimer) { predicate.delayTimer = window.setTimeout(function() { predicate.delay = 0; if (drag._resolveStartPredicate(predicate.event)) { drag._forceResolveStartPredicate(predicate.event); drag._resetStartPredicate(); } }, predicate.delay); } } return drag._resolveStartPredicate(event); }; /** * Default drag sort predicate. * * @public * @memberof ItemDrag * @param {Item} item * @param {Object} [options] * @param {Number} [options.threshold=50] * @param {String} [options.action='move'] * @returns {(Boolean|DragSortCommand)} * - Returns false if no valid index was found. Otherwise returns drag sort * command. */ ItemDrag.defaultSortPredicate = (function() { var itemRect = {}; var targetRect = {}; var returnData = {}; var rootGridArray = []; function getTargetGrid(item, rootGrid, threshold) { var target = null; var dragSort = rootGrid._settings.dragSort; var bestScore = -1; var gridScore; var grids; var grid; var i; // Get potential target grids. if (dragSort === true) { rootGridArray[0] = rootGrid; grids = rootGridArray; } else { grids = dragSort.call(rootGrid, item); } // Return immediately if there are no grids. if (!Array.isArray(grids)) return target; // Loop through the grids and get the best match. for (i = 0; i < grids.length; i++) { grid = grids[i]; // Filter out all destroyed grids. if (grid._isDestroyed) continue; // We need to update the grid's offsets and dimensions since they might // have changed (e.g during scrolling). grid._updateBoundingRect(); // Check how much dragged element overlaps the container element. targetRect.width = grid._width; targetRect.height = grid._height; targetRect.left = grid._left; targetRect.top = grid._top; gridScore = getRectOverlapScore(itemRect, targetRect); // Check if this grid is the best match so far. if (gridScore > threshold && gridScore > bestScore) { bestScore = gridScore; target = grid; } } // Always reset root grid array. rootGridArray.length = 0; return target; } return function(item, options) { var drag = item._drag; var rootGrid = drag._getGrid(); // Get drag sort predicate settings. var sortThreshold = options && typeof options.threshold === 'number' ? options.threshold : 50; var sortAction = options && options.action === 'swap' ? 'swap' : 'move'; // Populate item rect data. itemRect.width = item._width; itemRect.height = item._height; itemRect.left = drag._elementClientX; itemRect.top = drag._elementClientY; // Calculate the target grid. var grid = getTargetGrid(item, rootGrid, sortThreshold); // Return early if we found no grid container element that overlaps the // dragged item enough. if (!grid) return false; var gridOffsetLeft = 0; var gridOffsetTop = 0; var matchScore = -1; var matchIndex; var hasValidTargets; var target; var score; var i; // If item is moved within it's originating grid adjust item's left and // top props. Otherwise if item is moved to/within another grid get the // container element's offset (from the element's content edge). if (grid === rootGrid) { itemRect.left = drag._gridX + item._marginLeft; itemRect.top = drag._gridY + item._marginTop; } else { grid._updateBorders(1, 0, 1, 0); gridOffsetLeft = grid._left + grid._borderLeft; gridOffsetTop = grid._top + grid._borderTop; } // Loop through the target grid items and try to find the best match. for (i = 0; i < grid._items.length; i++) { target = grid._items[i]; // If the target item is not active or the target item is the dragged // item let's skip to the next item. if (!target._isActive || target === item) { continue; } // Mark the grid as having valid target items. hasValidTargets = true; // Calculate the target's overlap score with the dragged item. targetRect.width = target._width; targetRect.height = target._height; targetRect.left = target._left + target._marginLeft + gridOffsetLeft; targetRect.top = target._top + target._marginTop + gridOffsetTop; score = getRectOverlapScore(itemRect, targetRect); // Update best match index and score if the target's overlap score with // the dragged item is higher than the current best match score. if (score > matchScore) { matchIndex = i; matchScore = score; } } // If there is no valid match and the item is being moved into another // grid. if (matchScore < sortThreshold && item.getGrid() !== grid) { matchIndex = hasValidTargets ? -1 : 0; matchScore = Infinity; } // Check if the best match overlaps enough to justify a placement switch. if (matchScore >= sortThreshold) { returnData.grid = grid; returnData.index = matchIndex; returnData.action = sortAction; return returnData; } return false; }; })(); /** * Public prototype methods * ************************ */ /** * Abort dragging and reset drag data. * * @public * @memberof ItemDrag.prototype * @returns {ItemDrag} */ ItemDrag.prototype.stop = function() { var item = this._item; var element = item._element; var grid = this._getGrid(); if (!this._isActive) return this; // If the item is being dropped into another grid, finish it up and return // immediately. if (this._isMigrating) { this._finishMigration(); return this; } // Cancel queued move and scroll ticks. cancelMoveTick(item._id); cancelScrollTick(item._id); // Remove scroll listeners. this._unbindScrollListeners(); // Cancel overlap check. this._checkOverlapDebounce('cancel'); // Append item element to the container if it's not it's child. Also make // sure the translate values are adjusted to account for the DOM shift. if (element.parentNode !== grid._element) { grid._element.appendChild(element); element.style[transformProp] = getTranslateString(this._gridX, this._gridY); } // Remove dragging class. removeClass(element, grid._settings.itemDraggingClass); // Reset drag data. this._reset(); return this; }; /** * Destroy instance. * * @public * @memberof ItemDrag.prototype * @returns {ItemDrag} */ ItemDrag.prototype.destroy = function() { if (this._isDestroyed) return this; this.stop(); this._hammer.destroy(); this._item._element.removeEventListener('dragstart', preventDefault, false); this._isDestroyed = true; return this; }; /** * Private prototype methods * ************************* */ /** * Get Grid instance. * * @private * @memberof ItemDrag.prototype * @returns {?Grid} */ ItemDrag.prototype._getGrid = function() { return gridInstances[this._gridId] || null; }; /** * Setup/reset drag data. * * @private * @memberof ItemDrag.prototype */ ItemDrag.prototype._reset = function() { // Is item being dragged? this._isActive = false; // The dragged item's container element. this._container = null; // The dragged item's containing block. this._containingBlock = null; // Hammer event data. this._lastEvent = null; this._lastScrollEvent = null; // All the elements which need to be listened for scroll events during // dragging. this._scrollers = []; // The current translateX/translateY position. this._left = 0; this._top = 0; // Dragged element's current position within the grid. this._gridX = 0; this._gridY = 0; // Dragged element's current offset from window's northwest corner. Does // not account for element's margins. this._elementClientX = 0; this._elementClientY = 0; // Offset difference between the dragged element's temporary drag // container and it's original container. this._containerDiffX = 0; this._containerDiffY = 0; }; /** * Bind drag scroll handlers to all scrollable ancestor elements of the * dragged element and the drag container element. * * @private * @memberof ItemDrag.prototype */ ItemDrag.prototype._bindScrollListeners = function() { var gridContainer = this._getGrid()._element; var dragContainer = this._container; var scrollers = this._scrollers; var containerScrollers; var i; // Get dragged element's scrolling parents. scrollers.length = 0; getScrollParents(this._item._element, scrollers); // If drag container is defined and it's not the same element as grid // container then we need to add the grid container and it's scroll parents // to the elements which are going to be listener for scroll events. if (dragContainer !== gridContainer) { containerScrollers = []; getScrollParents(gridContainer, containerScrollers); containerScrollers.push(gridContainer); for (i = 0; i < containerScrollers.length; i++) { if (scrollers.indexOf(containerScrollers[i]) < 0) { scrollers.push(containerScrollers[i]); } } } // Bind scroll listeners. for (i = 0; i < scrollers.length; i++) { scrollers[i].addEventListener('scroll', this._onScroll); } }; /** * Unbind currently bound drag scroll handlers from all scrollable ancestor * elements of the dragged element and the drag container element. * * @private * @memberof ItemDrag.prototype */ ItemDrag.prototype._unbindScrollListeners = function() { var scrollers = this._scrollers; var i; for (i = 0; i < scrollers.length; i++) { scrollers[i].removeEventListener('scroll', this._onScroll); } scrollers.length = 0; }; /** * Setup default start predicate. * * @private * @memberof ItemDrag.prototype * @param {Object} [options] * @returns {Object} */ ItemDrag.prototype._setupStartPredicate = function(options) { var config = options || this._getGrid()._settings.dragStartPredicate || 0; return (this._startPredicateData = { distance: Math.abs(config.distance) || 0, delay: Math.max(config.delay, 0) || 0, handle: typeof config.handle === 'string' ? config.handle : false }); }; /** * Setup default start predicate handle. * * @private * @memberof ItemDrag.prototype * @param {Object} event * @returns {?HTMLElement} */ ItemDrag.prototype._getStartPredicateHandle = function(event) { var predicate = this._startPredicateData; var element = this._item._element; var handleElement = element; // No handle, no hassle -> let's use the item element as the handle. if (!predicate.handle) return handleElement; // If there is a specific predicate handle defined, let's try to get it. handleElement = (event.changedPointers[0] || 0).target; while (handleElement && !elementMatches(handleElement, predicate.handle)) { handleElement = handleElement !== element ? handleElement.parentElement : null; } return handleElement || null; }; /** * Unbind currently bound drag scroll handlers from all scrollable ancestor * elements of the dragged element and the drag container element. * * @private * @memberof ItemDrag.prototype * @param {Object} event * @returns {Boolean} */ ItemDrag.prototype._resolveStartPredicate = function(event) { var predicate = this._startPredicateData; var pointer = event.changedPointers[0]; var pageX = (pointer && pointer.pageX) || 0; var pageY = (pointer && pointer.pageY) || 0; var handleRect; var handleLeft; var handleTop; var handleWidth; var handleHeight; // If the moved distance is smaller than the threshold distance or there is // some delay left, ignore this predicate cycle. if (event.distance < predicate.distance || predicate.delay) return; // Get handle rect data. handleRect = predicate.handleElement.getBoundingClientRect(); handleLeft = handleRect.left + (window.pageXOffset || 0); handleTop = handleRect.top + (window.pageYOffset || 0); handleWidth = handleRect.width; handleHeight = handleRect.height; // Reset predicate data. this._resetStartPredicate(); // If the cursor is still within the handle let's start the drag. return ( handleWidth && handleHeight && pageX >= handleLeft && pageX < handleLeft + handleWidth && pageY >= handleTop && pageY < handleTop + handleHeight ); }; /** * Finalize start predicate. * * @private * @memberof ItemDrag.prototype * @param {Object} event */ ItemDrag.prototype._finishStartPredicate = function(event) { var element = this._item._element; // Reset predicate. this._resetStartPredicate(); // If the gesture can be interpreted as click let's try to open the element's // href url (if it is an anchor element). if (isClick(event)) openAnchorHref(element); }; /** * Reset for default drag start predicate function. * * @private * @memberof ItemDrag.prototype */ ItemDrag.prototype._resetStartPredicate = function() { var predicate = this._startPredicateData; if (predicate) { if (predicate.delayTimer) { predicate.delayTimer = window.clearTimeout(predicate.delayTimer); } this._startPredicateData = null; } }; /** * Check (during drag) if an item is overlapping other items and based on * the configuration layout the items. * * @private * @memberof ItemDrag.prototype */ ItemDrag.prototype._checkOverlap = function() { if (!this._isActive) return; var item = this._item; var settings = this._getGrid()._settings; var result; var currentGrid; var currentIndex; var targetGrid; var targetIndex; var sor