UNPKG

muuri

Version:

Responsive, sortable, filterable and draggable layouts

1,783 lines (1,548 loc) 264 kB
/** * Muuri v0.9.5 * https://muuri.dev/ * 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 Dragger * Copyright (c) 2018-present, Niklas Rämö <inramo@gmail.com> * @license MIT * * Muuri AutoScroller * Copyright (c) 2019-present, Niklas Rämö <inramo@gmail.com> * @license MIT */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Muuri = factory()); }(this, (function () { 'use strict'; var GRID_INSTANCES = {}; var ITEM_ELEMENT_MAP = typeof Map === 'function' ? new Map() : null; var ACTION_SWAP = 'swap'; var ACTION_MOVE = 'move'; var EVENT_SYNCHRONIZE = 'synchronize'; var EVENT_LAYOUT_START = 'layoutStart'; var EVENT_LAYOUT_END = 'layoutEnd'; var EVENT_LAYOUT_ABORT = 'layoutAbort'; var EVENT_ADD = 'add'; var EVENT_REMOVE = 'remove'; var EVENT_SHOW_START = 'showStart'; var EVENT_SHOW_END = 'showEnd'; var EVENT_HIDE_START = 'hideStart'; var EVENT_HIDE_END = 'hideEnd'; var EVENT_FILTER = 'filter'; var EVENT_SORT = 'sort'; var EVENT_MOVE = 'move'; var EVENT_SEND = 'send'; var EVENT_BEFORE_SEND = 'beforeSend'; var EVENT_RECEIVE = 'receive'; var EVENT_BEFORE_RECEIVE = 'beforeReceive'; var EVENT_DRAG_INIT = 'dragInit'; var EVENT_DRAG_START = 'dragStart'; var EVENT_DRAG_MOVE = 'dragMove'; var EVENT_DRAG_SCROLL = 'dragScroll'; var EVENT_DRAG_END = 'dragEnd'; var EVENT_DRAG_RELEASE_START = 'dragReleaseStart'; var EVENT_DRAG_RELEASE_END = 'dragReleaseEnd'; var EVENT_DESTROY = 'destroy'; var HAS_TOUCH_EVENTS = 'ontouchstart' in window; var HAS_POINTER_EVENTS = !!window.PointerEvent; var HAS_MS_POINTER_EVENTS = !!window.navigator.msPointerEnabled; var MAX_SAFE_FLOAT32_INTEGER = 16777216; /** * Event emitter constructor. * * @class */ function Emitter() { this._events = {}; this._queue = []; this._counter = 0; this._clearOnEmit = false; } /** * Public prototype methods * ************************ */ /** * Bind an event listener. * * @public * @param {String} event * @param {Function} listener * @returns {Emitter} */ Emitter.prototype.on = function (event, listener) { if (!this._events || !event || !listener) 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; }; /** * Unbind all event listeners that match the provided listener function. * * @public * @param {String} event * @param {Function} listener * @returns {Emitter} */ Emitter.prototype.off = function (event, listener) { if (!this._events || !event || !listener) return this; // Get listeners and return immediately if none is found. var listeners = this._events[event]; if (!listeners || !listeners.length) return this; // Remove all matching listeners. var index; while ((index = listeners.indexOf(listener)) !== -1) { listeners.splice(index, 1); } return this; }; /** * Unbind all listeners of the provided event. * * @public * @param {String} event * @returns {Emitter} */ Emitter.prototype.clear = function (event) { if (!this._events || !event) return this; var listeners = this._events[event]; if (listeners) { listeners.length = 0; delete this._events[event]; } return this; }; /** * Emit all listeners in a specified event with the provided arguments. * * @public * @param {String} event * @param {...*} [args] * @returns {Emitter} */ Emitter.prototype.emit = function (event) { if (!this._events || !event) { this._clearOnEmit = false; return this; } // Get event listeners and quit early if there's no listeners. var listeners = this._events[event]; if (!listeners || !listeners.length) { this._clearOnEmit = false; return this; } var queue = this._queue; var startIndex = queue.length; var argsLength = arguments.length - 1; var args; // If we have more than 3 arguments let's put the arguments in an array and // apply it to the listeners. if (argsLength > 3) { args = []; args.push.apply(args, arguments); args.shift(); } // 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. queue.push.apply(queue, listeners); // Reset the event's listeners if need be. if (this._clearOnEmit) { listeners.length = 0; this._clearOnEmit = false; } // 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). var i = startIndex; var endIndex = queue.length; for (; i < endIndex; i++) { // prettier-ignore argsLength === 0 ? queue[i]() : argsLength === 1 ? queue[i](arguments[1]) : argsLength === 2 ? queue[i](arguments[1], arguments[2]) : argsLength === 3 ? queue[i](arguments[1], arguments[2], arguments[3]) : queue[i].apply(null, args); // Stop processing if the emitter is destroyed. if (!this._events) 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; }; /** * Emit all listeners in a specified event with the provided arguments and * remove the event's listeners just before calling the them. This method allows * the emitter to serve as a queue where all listeners are called only once. * * @public * @param {String} event * @param {...*} [args] * @returns {Emitter} */ Emitter.prototype.burst = function () { if (!this._events) return this; this._clearOnEmit = true; this.emit.apply(this, arguments); return this; }; /** * Check how many listeners there are for a specific event. * * @public * @param {String} event * @returns {Boolean} */ Emitter.prototype.countListeners = function (event) { if (!this._events) return 0; var listeners = this._events[event]; return listeners ? listeners.length : 0; }; /** * Destroy emitter instance. Basically just removes all bound listeners. * * @public * @returns {Emitter} */ Emitter.prototype.destroy = function () { if (!this._events) return this; this._queue.length = this._counter = 0; this._events = null; return this; }; var pointerout = HAS_POINTER_EVENTS ? 'pointerout' : HAS_MS_POINTER_EVENTS ? 'MSPointerOut' : ''; var waitDuration = 100; /** * If you happen to use Edge or IE on a touch capable device there is a * a specific case where pointercancel and pointerend events are never emitted, * even though one them should always be emitted when you release your finger * from the screen. The bug appears specifically when Muuri shifts the dragged * element's position in the DOM after pointerdown event, IE and Edge don't like * that behaviour and quite often forget to emit the pointerend/pointercancel * event. But, they do emit pointerout event so we utilize that here. * Specifically, if there has been no pointermove event within 100 milliseconds * since the last pointerout event we force cancel the drag operation. This hack * works surprisingly well 99% of the time. There is that 1% chance there still * that dragged items get stuck but it is what it is. * * @class * @param {Dragger} dragger */ function EdgeHack(dragger) { if (!pointerout) return; this._dragger = dragger; this._timeout = null; this._outEvent = null; this._isActive = false; this._addBehaviour = this._addBehaviour.bind(this); this._removeBehaviour = this._removeBehaviour.bind(this); this._onTimeout = this._onTimeout.bind(this); this._resetData = this._resetData.bind(this); this._onStart = this._onStart.bind(this); this._onOut = this._onOut.bind(this); this._dragger.on('start', this._onStart); } /** * @private */ EdgeHack.prototype._addBehaviour = function () { if (this._isActive) return; this._isActive = true; this._dragger.on('move', this._resetData); this._dragger.on('cancel', this._removeBehaviour); this._dragger.on('end', this._removeBehaviour); window.addEventListener(pointerout, this._onOut); }; /** * @private */ EdgeHack.prototype._removeBehaviour = function () { if (!this._isActive) return; this._dragger.off('move', this._resetData); this._dragger.off('cancel', this._removeBehaviour); this._dragger.off('end', this._removeBehaviour); window.removeEventListener(pointerout, this._onOut); this._resetData(); this._isActive = false; }; /** * @private */ EdgeHack.prototype._resetData = function () { window.clearTimeout(this._timeout); this._timeout = null; this._outEvent = null; }; /** * @private * @param {(PointerEvent|TouchEvent|MouseEvent)} e */ EdgeHack.prototype._onStart = function (e) { if (e.pointerType === 'mouse') return; this._addBehaviour(); }; /** * @private * @param {(PointerEvent|TouchEvent|MouseEvent)} e */ EdgeHack.prototype._onOut = function (e) { if (!this._dragger._getTrackedTouch(e)) return; this._resetData(); this._outEvent = e; this._timeout = window.setTimeout(this._onTimeout, waitDuration); }; /** * @private */ EdgeHack.prototype._onTimeout = function () { var e = this._outEvent; this._resetData(); if (this._dragger.isActive()) this._dragger._onCancel(e); }; /** * @public */ EdgeHack.prototype.destroy = function () { if (!pointerout) return; this._dragger.off('start', this._onStart); this._removeBehaviour(); }; // Playing it safe here, test all potential prefixes capitalized and lowercase. var vendorPrefixes = ['', 'webkit', 'moz', 'ms', 'o', 'Webkit', 'Moz', 'MS', 'O']; var cache$2 = {}; /** * Get prefixed CSS property name when given a non-prefixed CSS property name. * Returns null if the property is not supported at all. * * @param {CSSStyleDeclaration} style * @param {String} prop * @returns {String} */ function getPrefixedPropName(style, prop) { var prefixedProp = cache$2[prop] || ''; if (prefixedProp) return prefixedProp; var camelProp = prop[0].toUpperCase() + prop.slice(1); var i = 0; while (i < vendorPrefixes.length) { prefixedProp = vendorPrefixes[i] ? vendorPrefixes[i] + camelProp : prop; if (prefixedProp in style) { cache$2[prop] = prefixedProp; return prefixedProp; } ++i; } return ''; } /** * Check if passive events are supported. * https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md#feature-detection * * @returns {Boolean} */ function hasPassiveEvents() { var isPassiveEventsSupported = false; try { var passiveOpts = Object.defineProperty({}, 'passive', { get: function () { isPassiveEventsSupported = true; }, }); window.addEventListener('testPassive', null, passiveOpts); window.removeEventListener('testPassive', null, passiveOpts); } catch (e) {} return isPassiveEventsSupported; } var ua = window.navigator.userAgent.toLowerCase(); var isEdge = ua.indexOf('edge') > -1; var isIE = ua.indexOf('trident') > -1; var isFirefox = ua.indexOf('firefox') > -1; var isAndroid = ua.indexOf('android') > -1; var listenerOptions = hasPassiveEvents() ? { passive: true } : false; var taProp = 'touchAction'; var taPropPrefixed = getPrefixedPropName(document.documentElement.style, taProp); var taDefaultValue = 'auto'; /** * Creates a new Dragger instance for an element. * * @public * @class * @param {HTMLElement} element * @param {Object} [cssProps] */ function Dragger(element, cssProps) { this._element = element; this._emitter = new Emitter(); this._isDestroyed = false; this._cssProps = {}; this._touchAction = ''; this._isActive = false; this._pointerId = null; this._startTime = 0; this._startX = 0; this._startY = 0; this._currentX = 0; this._currentY = 0; this._onStart = this._onStart.bind(this); this._onMove = this._onMove.bind(this); this._onCancel = this._onCancel.bind(this); this._onEnd = this._onEnd.bind(this); // Can't believe had to build a freaking class for a hack! this._edgeHack = null; if ((isEdge || isIE) && (HAS_POINTER_EVENTS || HAS_MS_POINTER_EVENTS)) { this._edgeHack = new EdgeHack(this); } // Apply initial CSS props. this.setCssProps(cssProps); // If touch action was not provided with initial CSS props let's assume it's // auto. if (!this._touchAction) { this.setTouchAction(taDefaultValue); } // Prevent native link/image dragging for the item and it's children. element.addEventListener('dragstart', Dragger._preventDefault, false); // Listen to start event. element.addEventListener(Dragger._inputEvents.start, this._onStart, listenerOptions); } /** * Protected properties * ******************** */ Dragger._pointerEvents = { start: 'pointerdown', move: 'pointermove', cancel: 'pointercancel', end: 'pointerup', }; Dragger._msPointerEvents = { start: 'MSPointerDown', move: 'MSPointerMove', cancel: 'MSPointerCancel', end: 'MSPointerUp', }; Dragger._touchEvents = { start: 'touchstart', move: 'touchmove', cancel: 'touchcancel', end: 'touchend', }; Dragger._mouseEvents = { start: 'mousedown', move: 'mousemove', cancel: '', end: 'mouseup', }; Dragger._inputEvents = (function () { if (HAS_TOUCH_EVENTS) return Dragger._touchEvents; if (HAS_POINTER_EVENTS) return Dragger._pointerEvents; if (HAS_MS_POINTER_EVENTS) return Dragger._msPointerEvents; return Dragger._mouseEvents; })(); Dragger._emitter = new Emitter(); Dragger._emitterEvents = { start: 'start', move: 'move', end: 'end', cancel: 'cancel', }; Dragger._activeInstances = []; /** * Protected static methods * ************************ */ Dragger._preventDefault = function (e) { if (e.preventDefault && e.cancelable !== false) e.preventDefault(); }; Dragger._activateInstance = function (instance) { var index = Dragger._activeInstances.indexOf(instance); if (index > -1) return; Dragger._activeInstances.push(instance); Dragger._emitter.on(Dragger._emitterEvents.move, instance._onMove); Dragger._emitter.on(Dragger._emitterEvents.cancel, instance._onCancel); Dragger._emitter.on(Dragger._emitterEvents.end, instance._onEnd); if (Dragger._activeInstances.length === 1) { Dragger._bindListeners(); } }; Dragger._deactivateInstance = function (instance) { var index = Dragger._activeInstances.indexOf(instance); if (index === -1) return; Dragger._activeInstances.splice(index, 1); Dragger._emitter.off(Dragger._emitterEvents.move, instance._onMove); Dragger._emitter.off(Dragger._emitterEvents.cancel, instance._onCancel); Dragger._emitter.off(Dragger._emitterEvents.end, instance._onEnd); if (!Dragger._activeInstances.length) { Dragger._unbindListeners(); } }; Dragger._bindListeners = function () { window.addEventListener(Dragger._inputEvents.move, Dragger._onMove, listenerOptions); window.addEventListener(Dragger._inputEvents.end, Dragger._onEnd, listenerOptions); if (Dragger._inputEvents.cancel) { window.addEventListener(Dragger._inputEvents.cancel, Dragger._onCancel, listenerOptions); } }; Dragger._unbindListeners = function () { window.removeEventListener(Dragger._inputEvents.move, Dragger._onMove, listenerOptions); window.removeEventListener(Dragger._inputEvents.end, Dragger._onEnd, listenerOptions); if (Dragger._inputEvents.cancel) { window.removeEventListener(Dragger._inputEvents.cancel, Dragger._onCancel, listenerOptions); } }; Dragger._getEventPointerId = function (event) { // If we have pointer id available let's use it. if (typeof event.pointerId === 'number') { return event.pointerId; } // For touch events let's get the first changed touch's identifier. if (event.changedTouches) { return event.changedTouches[0] ? event.changedTouches[0].identifier : null; } // For mouse/other events let's provide a static id. return 1; }; Dragger._getTouchById = function (event, id) { // If we have a pointer event return the whole event if there's a match, and // null otherwise. if (typeof event.pointerId === 'number') { return event.pointerId === id ? event : null; } // For touch events let's check if there's a changed touch object that matches // the pointerId in which case return the touch object. if (event.changedTouches) { for (var i = 0; i < event.changedTouches.length; i++) { if (event.changedTouches[i].identifier === id) { return event.changedTouches[i]; } } return null; } // For mouse/other events let's assume there's only one pointer and just // return the event. return event; }; Dragger._onMove = function (e) { Dragger._emitter.emit(Dragger._emitterEvents.move, e); }; Dragger._onCancel = function (e) { Dragger._emitter.emit(Dragger._emitterEvents.cancel, e); }; Dragger._onEnd = function (e) { Dragger._emitter.emit(Dragger._emitterEvents.end, e); }; /** * Private prototype methods * ************************* */ /** * Reset current drag operation (if any). * * @private */ Dragger.prototype._reset = function () { this._pointerId = null; this._startTime = 0; this._startX = 0; this._startY = 0; this._currentX = 0; this._currentY = 0; this._isActive = false; Dragger._deactivateInstance(this); }; /** * Create a custom dragger event from a raw event. * * @private * @param {String} type * @param {(PointerEvent|TouchEvent|MouseEvent)} e * @returns {Object} */ Dragger.prototype._createEvent = function (type, e) { var touch = this._getTrackedTouch(e); return { // Hammer.js compatibility interface. type: type, srcEvent: e, distance: this.getDistance(), deltaX: this.getDeltaX(), deltaY: this.getDeltaY(), deltaTime: type === Dragger._emitterEvents.start ? 0 : this.getDeltaTime(), isFirst: type === Dragger._emitterEvents.start, isFinal: type === Dragger._emitterEvents.end || type === Dragger._emitterEvents.cancel, pointerType: e.pointerType || (e.touches ? 'touch' : 'mouse'), // Partial Touch API interface. identifier: this._pointerId, screenX: touch.screenX, screenY: touch.screenY, clientX: touch.clientX, clientY: touch.clientY, pageX: touch.pageX, pageY: touch.pageY, target: touch.target, }; }; /** * Emit a raw event as dragger event internally. * * @private * @param {String} type * @param {(PointerEvent|TouchEvent|MouseEvent)} e */ Dragger.prototype._emit = function (type, e) { this._emitter.emit(type, this._createEvent(type, e)); }; /** * If the provided event is a PointerEvent this method will return it if it has * the same pointerId as the instance. If the provided event is a TouchEvent * this method will try to look for a Touch instance in the changedTouches that * has an identifier matching this instance's pointerId. If the provided event * is a MouseEvent (or just any other event than PointerEvent or TouchEvent) * it will be returned immediately. * * @private * @param {(PointerEvent|TouchEvent|MouseEvent)} e * @returns {?(Touch|PointerEvent|MouseEvent)} */ Dragger.prototype._getTrackedTouch = function (e) { if (this._pointerId === null) return null; return Dragger._getTouchById(e, this._pointerId); }; /** * Handler for start event. * * @private * @param {(PointerEvent|TouchEvent|MouseEvent)} e */ Dragger.prototype._onStart = function (e) { if (this._isDestroyed) return; // If pointer id is already assigned let's return early. if (this._pointerId !== null) return; // Get (and set) pointer id. this._pointerId = Dragger._getEventPointerId(e); if (this._pointerId === null) return; // Setup initial data and emit start event. var touch = this._getTrackedTouch(e); this._startX = this._currentX = touch.clientX; this._startY = this._currentY = touch.clientY; this._startTime = Date.now(); this._isActive = true; this._emit(Dragger._emitterEvents.start, e); // If the drag procedure was not reset within the start procedure let's // activate the instance (start listening to move/cancel/end events). if (this._isActive) { Dragger._activateInstance(this); } }; /** * Handler for move event. * * @private * @param {(PointerEvent|TouchEvent|MouseEvent)} e */ Dragger.prototype._onMove = function (e) { var touch = this._getTrackedTouch(e); if (!touch) return; this._currentX = touch.clientX; this._currentY = touch.clientY; this._emit(Dragger._emitterEvents.move, e); }; /** * Handler for cancel event. * * @private * @param {(PointerEvent|TouchEvent|MouseEvent)} e */ Dragger.prototype._onCancel = function (e) { if (!this._getTrackedTouch(e)) return; this._emit(Dragger._emitterEvents.cancel, e); this._reset(); }; /** * Handler for end event. * * @private * @param {(PointerEvent|TouchEvent|MouseEvent)} e */ Dragger.prototype._onEnd = function (e) { if (!this._getTrackedTouch(e)) return; this._emit(Dragger._emitterEvents.end, e); this._reset(); }; /** * Public prototype methods * ************************ */ /** * Check if the element is being dragged at the moment. * * @public * @returns {Boolean} */ Dragger.prototype.isActive = function () { return this._isActive; }; /** * Set element's touch-action CSS property. * * @public * @param {String} value */ Dragger.prototype.setTouchAction = function (value) { // Store unmodified touch action value (we trust user input here). this._touchAction = value; // Set touch-action style. if (taPropPrefixed) { this._cssProps[taPropPrefixed] = ''; this._element.style[taPropPrefixed] = value; } // If we have an unsupported touch-action value let's add a special listener // that prevents default action on touch start event. A dirty hack, but best // we can do for now. The other options would be to somehow polyfill the // unsupported touch action behavior with custom heuristics which sounds like // a can of worms. We do a special exception here for Firefox Android which's // touch-action does not work properly if the dragged element is moved in the // the DOM tree on touchstart. if (HAS_TOUCH_EVENTS) { this._element.removeEventListener(Dragger._touchEvents.start, Dragger._preventDefault, true); if (this._element.style[taPropPrefixed] !== value || (isFirefox && isAndroid)) { this._element.addEventListener(Dragger._touchEvents.start, Dragger._preventDefault, true); } } }; /** * Update element's CSS properties. Accepts an object with camel cased style * props with value pairs as it's first argument. * * @public * @param {Object} [newProps] */ Dragger.prototype.setCssProps = function (newProps) { if (!newProps) return; var currentProps = this._cssProps; var element = this._element; var prop; var prefixedProp; // Reset current props. for (prop in currentProps) { element.style[prop] = currentProps[prop]; delete currentProps[prop]; } // Set new props. for (prop in newProps) { // Make sure we have a value for the prop. if (!newProps[prop]) continue; // Special handling for touch-action. if (prop === taProp) { this.setTouchAction(newProps[prop]); continue; } // Get prefixed prop and skip if it does not exist. prefixedProp = getPrefixedPropName(element.style, prop); if (!prefixedProp) continue; // Store the prop and add the style. currentProps[prefixedProp] = ''; element.style[prefixedProp] = newProps[prop]; } }; /** * How much the pointer has moved on x-axis from start position, in pixels. * Positive value indicates movement from left to right. * * @public * @returns {Number} */ Dragger.prototype.getDeltaX = function () { return this._currentX - this._startX; }; /** * How much the pointer has moved on y-axis from start position, in pixels. * Positive value indicates movement from top to bottom. * * @public * @returns {Number} */ Dragger.prototype.getDeltaY = function () { return this._currentY - this._startY; }; /** * How far (in pixels) has pointer moved from start position. * * @public * @returns {Number} */ Dragger.prototype.getDistance = function () { var x = this.getDeltaX(); var y = this.getDeltaY(); return Math.sqrt(x * x + y * y); }; /** * How long has pointer been dragged. * * @public * @returns {Number} */ Dragger.prototype.getDeltaTime = function () { return this._startTime ? Date.now() - this._startTime : 0; }; /** * Bind drag event listeners. * * @public * @param {String} eventName * - 'start', 'move', 'cancel' or 'end'. * @param {Function} listener */ Dragger.prototype.on = function (eventName, listener) { this._emitter.on(eventName, listener); }; /** * Unbind drag event listeners. * * @public * @param {String} eventName * - 'start', 'move', 'cancel' or 'end'. * @param {Function} listener */ Dragger.prototype.off = function (eventName, listener) { this._emitter.off(eventName, listener); }; /** * Destroy the instance and unbind all drag event listeners. * * @public */ Dragger.prototype.destroy = function () { if (this._isDestroyed) return; var element = this._element; if (this._edgeHack) this._edgeHack.destroy(); // Reset data and deactivate the instance. this._reset(); // Destroy emitter. this._emitter.destroy(); // Unbind event handlers. element.removeEventListener(Dragger._inputEvents.start, this._onStart, listenerOptions); element.removeEventListener('dragstart', Dragger._preventDefault, false); element.removeEventListener(Dragger._touchEvents.start, Dragger._preventDefault, true); // Reset styles. for (var prop in this._cssProps) { element.style[prop] = this._cssProps[prop]; delete this._cssProps[prop]; } // Reset data. this._element = null; // Mark as destroyed. this._isDestroyed = true; }; var dt = 1000 / 60; var raf = ( window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || function (callback) { return this.setTimeout(function () { callback(Date.now()); }, dt); } ).bind(window); /** * A ticker system for handling DOM reads and writes in an efficient way. * * @class */ function Ticker(numLanes) { this._nextStep = null; this._lanes = []; this._stepQueue = []; this._stepCallbacks = {}; this._step = this._step.bind(this); for (var i = 0; i < numLanes; i++) { this._lanes.push(new TickerLane()); } } Ticker.prototype._step = function (time) { var lanes = this._lanes; var stepQueue = this._stepQueue; var stepCallbacks = this._stepCallbacks; var i, j, id, laneQueue, laneCallbacks, laneIndices; this._nextStep = null; for (i = 0; i < lanes.length; i++) { laneQueue = lanes[i].queue; laneCallbacks = lanes[i].callbacks; laneIndices = lanes[i].indices; for (j = 0; j < laneQueue.length; j++) { id = laneQueue[j]; if (!id) continue; stepQueue.push(id); stepCallbacks[id] = laneCallbacks[id]; delete laneCallbacks[id]; delete laneIndices[id]; } laneQueue.length = 0; } for (i = 0; i < stepQueue.length; i++) { id = stepQueue[i]; if (stepCallbacks[id]) stepCallbacks[id](time); delete stepCallbacks[id]; } stepQueue.length = 0; }; Ticker.prototype.add = function (laneIndex, id, callback) { this._lanes[laneIndex].add(id, callback); if (!this._nextStep) this._nextStep = raf(this._step); }; Ticker.prototype.remove = function (laneIndex, id) { this._lanes[laneIndex].remove(id); }; /** * A lane for ticker. * * @class */ function TickerLane() { this.queue = []; this.indices = {}; this.callbacks = {}; } TickerLane.prototype.add = function (id, callback) { var index = this.indices[id]; if (index !== undefined) this.queue[index] = undefined; this.queue.push(id); this.callbacks[id] = callback; this.indices[id] = this.queue.length - 1; }; TickerLane.prototype.remove = function (id) { var index = this.indices[id]; if (index === undefined) return; this.queue[index] = undefined; delete this.callbacks[id]; delete this.indices[id]; }; var LAYOUT_READ = 'layoutRead'; var LAYOUT_WRITE = 'layoutWrite'; var VISIBILITY_READ = 'visibilityRead'; var VISIBILITY_WRITE = 'visibilityWrite'; var DRAG_START_READ = 'dragStartRead'; var DRAG_START_WRITE = 'dragStartWrite'; var DRAG_MOVE_READ = 'dragMoveRead'; var DRAG_MOVE_WRITE = 'dragMoveWrite'; var DRAG_SCROLL_READ = 'dragScrollRead'; var DRAG_SCROLL_WRITE = 'dragScrollWrite'; var DRAG_SORT_READ = 'dragSortRead'; var PLACEHOLDER_LAYOUT_READ = 'placeholderLayoutRead'; var PLACEHOLDER_LAYOUT_WRITE = 'placeholderLayoutWrite'; var PLACEHOLDER_RESIZE_WRITE = 'placeholderResizeWrite'; var AUTO_SCROLL_READ = 'autoScrollRead'; var AUTO_SCROLL_WRITE = 'autoScrollWrite'; var DEBOUNCE_READ = 'debounceRead'; var LANE_READ = 0; var LANE_READ_TAIL = 1; var LANE_WRITE = 2; var ticker = new Ticker(3); function addLayoutTick(itemId, read, write) { ticker.add(LANE_READ, LAYOUT_READ + itemId, read); ticker.add(LANE_WRITE, LAYOUT_WRITE + itemId, write); } function cancelLayoutTick(itemId) { ticker.remove(LANE_READ, LAYOUT_READ + itemId); ticker.remove(LANE_WRITE, LAYOUT_WRITE + itemId); } function addVisibilityTick(itemId, read, write) { ticker.add(LANE_READ, VISIBILITY_READ + itemId, read); ticker.add(LANE_WRITE, VISIBILITY_WRITE + itemId, write); } function cancelVisibilityTick(itemId) { ticker.remove(LANE_READ, VISIBILITY_READ + itemId); ticker.remove(LANE_WRITE, VISIBILITY_WRITE + itemId); } function addDragStartTick(itemId, read, write) { ticker.add(LANE_READ, DRAG_START_READ + itemId, read); ticker.add(LANE_WRITE, DRAG_START_WRITE + itemId, write); } function cancelDragStartTick(itemId) { ticker.remove(LANE_READ, DRAG_START_READ + itemId); ticker.remove(LANE_WRITE, DRAG_START_WRITE + itemId); } function addDragMoveTick(itemId, read, write) { ticker.add(LANE_READ, DRAG_MOVE_READ + itemId, read); ticker.add(LANE_WRITE, DRAG_MOVE_WRITE + itemId, write); } function cancelDragMoveTick(itemId) { ticker.remove(LANE_READ, DRAG_MOVE_READ + itemId); ticker.remove(LANE_WRITE, DRAG_MOVE_WRITE + itemId); } function addDragScrollTick(itemId, read, write) { ticker.add(LANE_READ, DRAG_SCROLL_READ + itemId, read); ticker.add(LANE_WRITE, DRAG_SCROLL_WRITE + itemId, write); } function cancelDragScrollTick(itemId) { ticker.remove(LANE_READ, DRAG_SCROLL_READ + itemId); ticker.remove(LANE_WRITE, DRAG_SCROLL_WRITE + itemId); } function addDragSortTick(itemId, read) { ticker.add(LANE_READ_TAIL, DRAG_SORT_READ + itemId, read); } function cancelDragSortTick(itemId) { ticker.remove(LANE_READ_TAIL, DRAG_SORT_READ + itemId); } function addPlaceholderLayoutTick(itemId, read, write) { ticker.add(LANE_READ, PLACEHOLDER_LAYOUT_READ + itemId, read); ticker.add(LANE_WRITE, PLACEHOLDER_LAYOUT_WRITE + itemId, write); } function cancelPlaceholderLayoutTick(itemId) { ticker.remove(LANE_READ, PLACEHOLDER_LAYOUT_READ + itemId); ticker.remove(LANE_WRITE, PLACEHOLDER_LAYOUT_WRITE + itemId); } function addPlaceholderResizeTick(itemId, write) { ticker.add(LANE_WRITE, PLACEHOLDER_RESIZE_WRITE + itemId, write); } function cancelPlaceholderResizeTick(itemId) { ticker.remove(LANE_WRITE, PLACEHOLDER_RESIZE_WRITE + itemId); } function addAutoScrollTick(read, write) { ticker.add(LANE_READ, AUTO_SCROLL_READ, read); ticker.add(LANE_WRITE, AUTO_SCROLL_WRITE, write); } function cancelAutoScrollTick() { ticker.remove(LANE_READ, AUTO_SCROLL_READ); ticker.remove(LANE_WRITE, AUTO_SCROLL_WRITE); } function addDebounceTick(debounceId, read) { ticker.add(LANE_READ, DEBOUNCE_READ + debounceId, read); } function cancelDebounceTick(debounceId) { ticker.remove(LANE_READ, DEBOUNCE_READ + debounceId); } var AXIS_X = 1; var AXIS_Y = 2; var FORWARD = 4; var BACKWARD = 8; var LEFT = AXIS_X | BACKWARD; var RIGHT = AXIS_X | FORWARD; var UP = AXIS_Y | BACKWARD; var DOWN = AXIS_Y | FORWARD; var functionType = 'function'; /** * Check if a value is a function. * * @param {*} val * @returns {Boolean} */ function isFunction(val) { return typeof val === functionType; } var cache$1 = 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 = cache$1 && cache$1.get(element); if (!styles) { styles = window.getComputedStyle(element, null); if (cache$1) cache$1.set(element, styles); } return styles.getPropertyValue(style); } /** * 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 DOC_ELEM = document.documentElement; var BODY = document.body; var THRESHOLD_DATA = { value: 0, offset: 0 }; /** * @param {HTMLElement|Window} element * @returns {HTMLElement|Window} */ function getScrollElement(element) { if (element === window || element === DOC_ELEM || element === BODY) { return window; } else { return element; } } /** * @param {HTMLElement|Window} element * @returns {Number} */ function getScrollLeft(element) { return element === window ? element.pageXOffset : element.scrollLeft; } /** * @param {HTMLElement|Window} element * @returns {Number} */ function getScrollTop(element) { return element === window ? element.pageYOffset : element.scrollTop; } /** * @param {HTMLElement|Window} element * @returns {Number} */ function getScrollLeftMax(element) { if (element === window) { return DOC_ELEM.scrollWidth - DOC_ELEM.clientWidth; } else { return element.scrollWidth - element.clientWidth; } } /** * @param {HTMLElement|Window} element * @returns {Number} */ function getScrollTopMax(element) { if (element === window) { return DOC_ELEM.scrollHeight - DOC_ELEM.clientHeight; } else { return element.scrollHeight - element.clientHeight; } } /** * Get window's or element's client rectangle data relative to the element's * content dimensions (includes inner size + padding, excludes scrollbars, * borders and margins). * * @param {HTMLElement|Window} element * @returns {Rectangle} */ function getContentRect(element, result) { result = result || {}; if (element === window) { result.width = DOC_ELEM.clientWidth; result.height = DOC_ELEM.clientHeight; result.left = 0; result.right = result.width; result.top = 0; result.bottom = result.height; } else { var bcr = element.getBoundingClientRect(); var borderLeft = element.clientLeft || getStyleAsFloat(element, 'border-left-width'); var borderTop = element.clientTop || getStyleAsFloat(element, 'border-top-width'); result.width = element.clientWidth; result.height = element.clientHeight; result.left = bcr.left + borderLeft; result.right = result.left + result.width; result.top = bcr.top + borderTop; result.bottom = result.top + result.height; } return result; } /** * @param {Item} item * @returns {Object} */ function getItemAutoScrollSettings(item) { return item._drag._getGrid()._settings.dragAutoScroll; } /** * @param {Item} item */ function prepareItemScrollSync(item) { if (!item._drag) return; item._drag._prepareScroll(); } /** * @param {Item} item */ function applyItemScrollSync(item) { if (!item._drag || !item._isActive) return; var drag = item._drag; drag._scrollDiffX = drag._scrollDiffY = 0; item._setTranslate(drag._left, drag._top); } /** * Compute threshold value and edge offset. * * @param {Number} threshold * @param {Number} safeZone * @param {Number} itemSize * @param {Number} targetSize * @returns {Object} */ function computeThreshold(threshold, safeZone, itemSize, targetSize) { THRESHOLD_DATA.value = Math.min(targetSize / 2, threshold); THRESHOLD_DATA.offset = Math.max(0, itemSize + THRESHOLD_DATA.value * 2 + targetSize * safeZone - targetSize) / 2; return THRESHOLD_DATA; } function ScrollRequest() { this.reset(); } ScrollRequest.prototype.reset = function () { if (this.isActive) this.onStop(); this.item = null; this.element = null; this.isActive = false; this.isEnding = false; this.direction = null; this.value = null; this.maxValue = 0; this.threshold = 0; this.distance = 0; this.speed = 0; this.duration = 0; this.action = null; }; ScrollRequest.prototype.hasReachedEnd = function () { return FORWARD & this.direction ? this.value >= this.maxValue : this.value <= 0; }; ScrollRequest.prototype.computeCurrentScrollValue = function () { if (this.value === null) { return AXIS_X & this.direction ? getScrollLeft(this.element) : getScrollTop(this.element); } return Math.max(0, Math.min(this.value, this.maxValue)); }; ScrollRequest.prototype.computeNextScrollValue = function (deltaTime) { var delta = this.speed * (deltaTime / 1000); var nextValue = FORWARD & this.direction ? this.value + delta : this.value - delta; return Math.max(0, Math.min(nextValue, this.maxValue)); }; ScrollRequest.prototype.computeSpeed = (function () { var data = { direction: null, threshold: 0, distance: 0, value: 0, maxValue: 0, deltaTime: 0, duration: 0, isEnding: false, }; return function (deltaTime) { var item = this.item; var speed = getItemAutoScrollSettings(item).speed; if (isFunction(speed)) { data.direction = this.direction; data.threshold = this.threshold; data.distance = this.distance; data.value = this.value; data.maxValue = this.maxValue; data.duration = this.duration; data.speed = this.speed; data.deltaTime = deltaTime; data.isEnding = this.isEnding; return speed(item, this.element, data); } else { return speed; } }; })(); ScrollRequest.prototype.tick = function (deltaTime) { if (!this.isActive) { this.isActive = true; this.onStart(); } this.value = this.computeCurrentScrollValue(); this.speed = this.computeSpeed(deltaTime); this.value = this.computeNextScrollValue(deltaTime); this.duration += deltaTime; return this.value; }; ScrollRequest.prototype.onStart = function () { var item = this.item; var onStart = getItemAutoScrollSettings(item).onStart; if (isFunction(onStart)) onStart(item, this.element, this.direction); }; ScrollRequest.prototype.onStop = function () { var item = this.item; var onStop = getItemAutoScrollSettings(item).onStop; if (isFunction(onStop)) onStop(item, this.element, this.direction); // Manually nudge sort to happen. There's a good chance that the item is still // after the scroll stops which means that the next sort will be triggered // only after the item is moved or it's parent scrolled. if (item._drag) item._drag.sort(); }; function ScrollAction() { this.element = null; this.requestX = null; this.requestY = null; this.scrollLeft = 0; this.scrollTop = 0; } ScrollAction.prototype.reset = function () { if (this.requestX) this.requestX.action = null; if (this.requestY) this.requestY.action = null; this.element = null; this.requestX = null; this.requestY = null; this.scrollLeft = 0; this.scrollTop = 0; }; ScrollAction.prototype.addRequest = function (request) { if (AXIS_X & request.direction) { this.removeRequest(this.requestX); this.requestX = request; } else { this.removeRequest(this.requestY); this.requestY = request; } request.action = this; }; ScrollAction.prototype.removeRequest = function (request) { if (!request) return; if (this.requestX === request) { this.requestX = null; request.action = null; } else if (this.requestY === request) { this.requestY = null; request.action = null; } }; ScrollAction.prototype.computeScrollValues = function () { this.scrollLeft = this.requestX ? this.requestX.value : getScrollLeft(this.element); this.scrollTop = this.requestY ? this.requestY.value : getScrollTop(this.element); }; ScrollAction.prototype.scroll = function () { var element = this.element; if (!element) return; if (element.scrollTo) { element.scrollTo(this.scrollLeft, this.scrollTop); } else { element.scrollLeft = this.scrollLeft; element.scrollTop = this.scrollTop; } }; function Pool(createItem, releaseItem) { this.pool = []; this.createItem = createItem; this.releaseItem = releaseItem; } Pool.prototype.pick = function () { return this.pool.pop() || this.createItem(); }; Pool.prototype.release = function (item) { this.releaseItem(item); if (this.pool.indexOf(item) !== -1) return; this.pool.push(item); }; Pool.prototype.reset = function () { this.pool.length = 0; }; /** * Check if two rectangles are overlapping. * * @param {Object} a * @param {Object} b * @returns {Number} */ function isOverlapping(a, b) { return !( a.left + a.width <= b.left || b.left + b.width <= a.left || a.top + a.height <= b.top || b.top + b.height <= a.top ); } /** * Calculate intersection area between two rectangle. * * @param {Object} a * @param {Object} b * @returns {Number} */ function getIntersectionArea(a, b) { if (!isOverlapping(a, b)) return 0; var width = Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left); var height = Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top); return width * height; } /** * Calculate how many percent the intersection area of two rectangles is from * the maximum potential intersection area between the rectangles. * * @param {Object} a * @param {Object} b * @returns {Number} */ function getIntersectionScore(a, b) { var area = getIntersectionArea(a, b); if (!area) return 0; var maxArea = Math.min(a.width, b.width) * Math.min(a.height, b.height); return (area / maxArea) * 100; } var RECT_1 = { width: 0, height: 0, left: 0, right: 0, top: 0, bottom: 0, }; var RECT_2 = { width: 0, height: 0, left: 0, right: 0, top: 0, bottom: 0, }; function AutoScroller() { this._isDestroyed = false; this._isTicking = false; this._tickTime = 0; this._tickDeltaTime = 0; this._items = []; this._actions = []; this._requests = {}; this._requests[AXIS_X] = {}; this._requests[AXIS_Y] = {}; this._requestOverlapCheck = {}; this._dragPositions = {}; this._dragDirections = {}; this._overlapCheckInterval = 150; this._requestPool = new Pool( function () { return new ScrollRequest(); }, function (request) { request.reset(); } ); this._actionPool = new Pool( function () { return new ScrollAction(); }, function (action) { action.reset(); } ); this._readTick = this._readTick.bind(this); this._writeTick = this._writeTick.bind(this); } AutoScroller.AXIS_X = AXIS_X; AutoScroller.AXIS_Y = AXIS_Y; AutoScroller.FORWARD = FORWARD; AutoScroller.BACKWARD = BACKWARD; AutoScroller.LEFT = LEFT; AutoScroller.RIGHT = RIGHT; AutoScroller.UP = UP; AutoScroller.DOWN = DOWN; AutoScroller.smoothSpeed = function (maxSpeed, acceleration, deceleration) { return function (item, element, data) { var targetSpeed = 0; if (!data.isEnding) { if (data.threshold > 0) { var factor = data.threshold - Math.max(0, data.distance); targetSpeed = (maxSpeed / data.threshold) * factor; } else { targetSpeed = maxSpeed; } } var currentSpeed = data.speed; var nextSpeed = targetSpeed; if (currentSpeed === targetSpeed) { return nextSpeed; } if (currentSpeed < targetSpeed) { nextSpeed = currentSpeed + acceleration * (data.deltaTime / 1000); return Math.min(targetSpeed, nextSpeed); } else { nextSpeed = currentSpeed - deceleration * (data.deltaTime / 1000); return Math.max(targetSpeed, nextSpeed); } }; }; AutoScroller.pointerHandle = function (pointerSize) { var rect = { left: 0, top: 0, width: 0, height: 0 }; var size = pointerSize || 1; return function (item, x, y, w, h, pX, pY) { rect.left = pX - size * 0.5; rect.top = pY - size * 0.5; rect.width = size; rect.height = size; return rect; }; }; AutoScroller.prototype._readTick = function (time) { if (this._isDestroyed) return; if (time && this._tickTime) { this._tickDeltaTime = time - this._tickTime; this._tickTime = time; this._updateRequests(); this._updateActions(); } else { this._tickTime = time; this._tickDeltaTime = 0; } }; AutoScroller.prototype._writeTick = function () { if (this._isDestroyed) return; this._applyActions(); addAutoScrollTick(this._readTick, this._writeTick); }; AutoScroller.prototype._startTicking = function () { this._isTicking = true; addAutoScrollTick(this._readTick, this._writeTick); }; AutoScroller.prototype._stopTicking = function () { this._isTicking = false; this._tickTime = 0; this._tickDeltaTime = 0; cancelAutoScrollTick(); }; AutoScroller.prototype._getItemHandleRect = function (item, handle, rect) { var itemDrag = item._drag; if (handle) { var ev = itemDrag._dragMoveEvent || itemDrag._dragStartEvent; var data = handle( item, itemDrag._clientX, itemDrag._clientY, item._width, item._height, ev.clientX, ev.clientY ); rect.left = data.left; rect.top = data.top; rect.width = data.width; rect.height = data.height; } else { rect.left = itemDrag._clientX; rect.top = itemDrag._clientY; rect.width = item._width; rect.height = item._height; } rect.right = rect.left + rect.width; rect.bottom = rect.top + rect.height; return rect; }; AutoScroller.prototype._requestItemScroll = function ( item, axis, element, direction, threshold, distance, maxValue ) { var reqMap = this._requests[axis]; var request = reqMap[ite