/** * Copyright (c) 2015-present, Haltu Oy * Released under the MIT license * https://github.com/haltu/muuri/blob/master/LICENSE.md */ import { ACTION_MOVE, ACTION_SWAP, EVENT_SYNCHRONIZE, EVENT_LAYOUT_START, EVENT_LAYOUT_ABORT, EVENT_LAYOUT_END, EVENT_ADD, EVENT_REMOVE, EVENT_SHOW_START, EVENT_SHOW_END, EVENT_HIDE_START, EVENT_HIDE_END, EVENT_FILTER, EVENT_SORT, EVENT_MOVE, EVENT_DESTROY, GRID_INSTANCES, ITEM_ELEMENT_MAP, MAX_SAFE_FLOAT32_INTEGER, } from '../constants'; import Item from '../Item/Item'; import ItemDrag from '../Item/ItemDrag'; import ItemDragPlaceholder from '../Item/ItemDragPlaceholder'; import ItemLayout from '../Item/ItemLayout'; import ItemMigrate from '../Item/ItemMigrate'; import ItemDragRelease from '../Item/ItemDragRelease'; import ItemVisibility from '../Item/ItemVisibility'; import Emitter from '../Emitter/Emitter'; import Animator from '../Animator/Animator'; import Packer from '../Packer/Packer'; import Dragger from '../Dragger/Dragger'; import AutoScroller from '../AutoScroller/AutoScroller'; import addClass from '../utils/addClass'; import arrayInsert from '../utils/arrayInsert'; import arrayMove from '../utils/arrayMove'; import arraySwap from '../utils/arraySwap'; import createUid from '../utils/createUid'; import debounce from '../utils/debounce'; import elementMatches from '../utils/elementMatches'; import getPrefixedPropName from '../utils/getPrefixedPropName'; import getStyle from '../utils/getStyle'; import getStyleAsFloat from '../utils/getStyleAsFloat'; import isFunction from '../utils/isFunction'; import isNodeList from '../utils/isNodeList'; import isPlainObject from '../utils/isPlainObject'; import noop from '../utils/noop'; import removeClass from '../utils/removeClass'; import setStyles from '../utils/setStyles'; import toArray from '../utils/toArray'; var NUMBER_TYPE = 'number'; var STRING_TYPE = 'string'; var INSTANT_LAYOUT = 'instant'; var layoutId = 0; /** * Creates a new Grid instance. * * @class * @param {(HTMLElement|String)} element * @param {Object} [options] * @param {(String|HTMLElement[]|NodeList|HTMLCollection)} [options.items="*"] * @param {Number} [options.showDuration=300] * @param {String} [options.showEasing="ease"] * @param {Object} [options.visibleStyles={opacity: "1", transform: "scale(1)"}] * @param {Number} [options.hideDuration=300] * @param {String} [options.hideEasing="ease"] * @param {Object} [options.hiddenStyles={opacity: "0", transform: "scale(0.5)"}] * @param {(Function|Object)} [options.layout] * @param {Boolean} [options.layout.fillGaps=false] * @param {Boolean} [options.layout.horizontal=false] * @param {Boolean} [options.layout.alignRight=false] * @param {Boolean} [options.layout.alignBottom=false] * @param {Boolean} [options.layout.rounding=false] * @param {(Boolean|Number)} [options.layoutOnResize=150] * @param {Boolean} [options.layoutOnInit=true] * @param {Number} [options.layoutDuration=300] * @param {String} [options.layoutEasing="ease"] * @param {?Object} [options.sortData=null] * @param {Boolean} [options.dragEnabled=false] * @param {?String} [options.dragHandle=null] * @param {?HtmlElement} [options.dragContainer=null] * @param {?Function} [options.dragStartPredicate] * @param {Number} [options.dragStartPredicate.distance=0] * @param {Number} [options.dragStartPredicate.delay=0] * @param {String} [options.dragAxis="xy"] * @param {(Boolean|Function)} [options.dragSort=true] * @param {Object} [options.dragSortHeuristics] * @param {Number} [options.dragSortHeuristics.sortInterval=100] * @param {Number} [options.dragSortHeuristics.minDragDistance=10] * @param {Number} [options.dragSortHeuristics.minBounceBackAngle=1] * @param {(Function|Object)} [options.dragSortPredicate] * @param {Number} [options.dragSortPredicate.threshold=50] * @param {String} [options.dragSortPredicate.action="move"] * @param {String} [options.dragSortPredicate.migrateAction="move"] * @param {Object} [options.dragRelease] * @param {Number} [options.dragRelease.duration=300] * @param {String} [options.dragRelease.easing="ease"] * @param {Boolean} [options.dragRelease.useDragContainer=true] * @param {Object} [options.dragCssProps] * @param {Object} [options.dragPlaceholder] * @param {Boolean} [options.dragPlaceholder.enabled=false] * @param {?Function} [options.dragPlaceholder.createElement=null] * @param {?Function} [options.dragPlaceholder.onCreate=null] * @param {?Function} [options.dragPlaceholder.onRemove=null] * @param {Object} [options.dragAutoScroll] * @param {(Function|Array)} [options.dragAutoScroll.targets=[]] * @param {?Function} [options.dragAutoScroll.handle=null] * @param {Number} [options.dragAutoScroll.threshold=50] * @param {Number} [options.dragAutoScroll.safeZone=0.2] * @param {(Function|Number)} [options.dragAutoScroll.speed] * @param {Boolean} [options.dragAutoScroll.sortDuringScroll=true] * @param {Boolean} [options.dragAutoScroll.smoothStop=false] * @param {?Function} [options.dragAutoScroll.onStart=null] * @param {?Function} [options.dragAutoScroll.onStop=null] * @param {String} [options.containerClass="muuri"] * @param {String} [options.itemClass="muuri-item"] * @param {String} [options.itemVisibleClass="muuri-item-visible"] * @param {String} [options.itemHiddenClass="muuri-item-hidden"] * @param {String} [options.itemPositioningClass="muuri-item-positioning"] * @param {String} [options.itemDraggingClass="muuri-item-dragging"] * @param {String} [options.itemReleasingClass="muuri-item-releasing"] * @param {String} [options.itemPlaceholderClass="muuri-item-placeholder"] */ function Grid(element, options) { // Allow passing element as selector string if (typeof element === STRING_TYPE) { element = document.querySelector(element); } // Throw an error if the container element is not body element or does not // exist within the body element. var isElementInDom = element.getRootNode ? element.getRootNode({ composed: true }) === document : document.body.contains(element); if (!isElementInDom || element === document.documentElement) { throw new Error('Container element must be an existing DOM element.'); } // Create instance settings by merging the options with default options. var settings = mergeSettings(Grid.defaultOptions, options); settings.visibleStyles = normalizeStyles(settings.visibleStyles); settings.hiddenStyles = normalizeStyles(settings.hiddenStyles); if (!isFunction(settings.dragSort)) { settings.dragSort = !!settings.dragSort; } this._id = createUid(); this._element = element; this._settings = settings; this._isDestroyed = false; this._items = []; this._layout = { id: 0, items: [], slots: [], }; this._isLayoutFinished = true; this._nextLayoutData = null; this._emitter = new Emitter(); this._onLayoutDataReceived = this._onLayoutDataReceived.bind(this); // Store grid instance to the grid instances collection. GRID_INSTANCES[this._id] = this; // Add container element's class name. addClass(element, settings.containerClass); // If layoutOnResize option is a valid number sanitize it and bind the resize // handler. bindLayoutOnResize(this, settings.layoutOnResize); // Add initial items. this.add(getInitialGridElements(element, settings.items), { layout: false }); // Layout on init if necessary. if (settings.layoutOnInit) { this.layout(true); } } /** * Public properties * ***************** */ /** * @public * @static * @see Item */ Grid.Item = Item; /** * @public * @static * @see ItemLayout */ Grid.ItemLayout = ItemLayout; /** * @public * @static * @see ItemVisibility */ Grid.ItemVisibility = ItemVisibility; /** * @public * @static * @see ItemMigrate */ Grid.ItemMigrate = ItemMigrate; /** * @public * @static * @see ItemDrag */ Grid.ItemDrag = ItemDrag; /** * @public * @static * @see ItemDragRelease */ Grid.ItemDragRelease = ItemDragRelease; /** * @public * @static * @see ItemDragPlaceholder */ Grid.ItemDragPlaceholder = ItemDragPlaceholder; /** * @public * @static * @see Emitter */ Grid.Emitter = Emitter; /** * @public * @static * @see Animator */ Grid.Animator = Animator; /** * @public * @static * @see Dragger */ Grid.Dragger = Dragger; /** * @public * @static * @see Packer */ Grid.Packer = Packer; /** * @public * @static * @see AutoScroller */ Grid.AutoScroller = AutoScroller; /** * The default Packer instance used by default for all layouts. * * @public * @static * @type {Packer} */ Grid.defaultPacker = new Packer(2); /** * Default options for Grid instance. * * @public * @static * @type {Object} */ Grid.defaultOptions = { // Initial item elements items: '*', // Default show animation showDuration: 300, showEasing: 'ease', // Default hide animation hideDuration: 300, hideEasing: 'ease', // Item's visible/hidden state styles visibleStyles: { opacity: '1', transform: 'scale(1)', }, hiddenStyles: { opacity: '0', transform: 'scale(0.5)', }, // Layout layout: { fillGaps: false, horizontal: false, alignRight: false, alignBottom: false, rounding: false, }, layoutOnResize: 150, layoutOnInit: true, layoutDuration: 300, layoutEasing: 'ease', // Sorting sortData: null, // Drag & Drop dragEnabled: false, dragContainer: null, dragHandle: null, dragStartPredicate: { distance: 0, delay: 0, }, dragAxis: 'xy', dragSort: true, dragSortLock: null, dragSortHeuristics: { sortInterval: 100, minDragDistance: 10, minBounceBackAngle: 1, }, dragSortPredicate: { threshold: 50, action: ACTION_MOVE, migrateAction: ACTION_MOVE, }, dragRelease: { duration: 300, easing: 'ease', useDragContainer: true, }, dragCssProps: { touchAction: 'none', userSelect: 'none', userDrag: 'none', tapHighlightColor: 'rgba(0, 0, 0, 0)', touchCallout: 'none', contentZooming: 'none', }, dragPlaceholder: { enabled: false, createElement: null, onCreate: null, onRemove: null, }, dragAutoScroll: { targets: [], handle: null, threshold: 50, safeZone: 0.2, speed: AutoScroller.smoothSpeed(1000, 2000, 2500), sortDuringScroll: true, smoothStop: false, onStart: null, onStop: null, }, // Classnames containerClass: 'muuri', itemClass: 'muuri-item', itemVisibleClass: 'muuri-item-shown', itemHiddenClass: 'muuri-item-hidden', itemPositioningClass: 'muuri-item-positioning', itemDraggingClass: 'muuri-item-dragging', itemReleasingClass: 'muuri-item-releasing', itemPlaceholderClass: 'muuri-item-placeholder', }; /** * Public prototype methods * ************************ */ /** * Bind an event listener. * * @public * @param {String} event * @param {Function} listener * @returns {Grid} */ Grid.prototype.on = function (event, listener) { this._emitter.on(event, listener); return this; }; /** * Unbind an event listener. * * @public * @param {String} event * @param {Function} listener * @returns {Grid} */ Grid.prototype.off = function (event, listener) { this._emitter.off(event, listener); return this; }; /** * Get the container element. * * @public * @returns {HTMLElement} */ Grid.prototype.getElement = function () { return this._element; }; /** * Get instance's item by element or by index. Target can also be an Item * instance in which case the function returns the item if it exists within * related Grid instance. If nothing is found with the provided target, null * is returned. * * @private * @param {(HtmlElement|Number|Item)} [target] * @returns {?Item} */ Grid.prototype.getItem = function (target) { // If no target is specified or the instance is destroyed, return null. if (this._isDestroyed || (!target && target !== 0)) { return null; } // If target is number return the item in that index. If the number is lower // than zero look for the item starting from the end of the items array. For // example -1 for the last item, -2 for the second last item, etc. if (typeof target === NUMBER_TYPE) { return this._items[target > -1 ? target : this._items.length + target] || null; } // If the target is an instance of Item return it if it is attached to this // Grid instance, otherwise return null. if (target instanceof Item) { return target._gridId === this._id ? target : null; } // In other cases let's assume that the target is an element, so let's try // to find an item that matches the element and return it. If item is not // found return null. if (ITEM_ELEMENT_MAP) { var item = ITEM_ELEMENT_MAP.get(target); return item && item._gridId === this._id ? item : null; } else { for (var i = 0; i < this._items.length; i++) { if (this._items[i]._element === target) { return this._items[i]; } } } return null; }; /** * Get all items. Optionally you can provide specific targets (elements, * indices and item instances). All items that are not found are omitted from * the returned array. * * @public * @param {(HtmlElement|Number|Item|Array)} [targets] * @returns {Item[]} */ Grid.prototype.getItems = function (targets) { // Return all items immediately if no targets were provided or if the // instance is destroyed. if (this._isDestroyed || targets === undefined) { return this._items.slice(0); } var items = []; var i, item; if (Array.isArray(targets) || isNodeList(targets)) { for (i = 0; i < targets.length; i++) { item = this.getItem(targets[i]); if (item) items.push(item); } } else { item = this.getItem(targets); if (item) items.push(item); } return items; }; /** * Update the cached dimensions of the instance's items. By default all the * items are refreshed, but you can also provide an array of target items as the * first argument if you want to refresh specific items. Note that all hidden * items are not refreshed by default since their "display" property is "none" * and their dimensions are therefore not readable from the DOM. However, if you * do want to force update hidden item dimensions too you can provide `true` * as the second argument, which makes the elements temporarily visible while * their dimensions are being read. * * @public * @param {Item[]} [items] * @param {Boolean} [force=false] * @returns {Grid} */ Grid.prototype.refreshItems = function (items, force) { if (this._isDestroyed) return this; var targets = items || this._items; var i, item, style, hiddenItemStyles; if (force === true) { hiddenItemStyles = []; for (i = 0; i < targets.length; i++) { item = targets[i]; if (!item.isVisible() && !item.isHiding()) { style = item.getElement().style; style.visibility = 'hidden'; style.display = ''; hiddenItemStyles.push(style); } } } for (i = 0; i < targets.length; i++) { targets[i]._refreshDimensions(force); } if (force === true) { for (i = 0; i < hiddenItemStyles.length; i++) { style = hiddenItemStyles[i]; style.visibility = ''; style.display = 'none'; } hiddenItemStyles.length = 0; } return this; }; /** * Update the sort data of the instance's items. By default all the items are * refreshed, but you can also provide an array of target items if you want to * refresh specific items. * * @public * @param {Item[]} [items] * @returns {Grid} */ Grid.prototype.refreshSortData = function (items) { if (this._isDestroyed) return this; var targets = items || this._items; for (var i = 0; i < targets.length; i++) { targets[i]._refreshSortData(); } return this; }; /** * Synchronize the item elements to match the order of the items in the DOM. * This comes handy if you need to keep the DOM structure matched with the * order of the items. Note that if an item's element is not currently a child * of the container element (if it is dragged for example) it is ignored and * left untouched. * * @public * @returns {Grid} */ Grid.prototype.synchronize = function () { if (this._isDestroyed) return this; var items = this._items; if (!items.length) return this; var fragment; var element; for (var i = 0; i < items.length; i++) { element = items[i]._element; if (element.parentNode === this._element) { fragment = fragment || document.createDocumentFragment(); fragment.appendChild(element); } } if (!fragment) return this; this._element.appendChild(fragment); this._emit(EVENT_SYNCHRONIZE); return this; }; /** * Calculate and apply item positions. * * @public * @param {Boolean} [instant=false] * @param {Function} [onFinish] * @returns {Grid} */ Grid.prototype.layout = function (instant, onFinish) { if (this._isDestroyed) return this; // Cancel unfinished layout algorithm if possible. var unfinishedLayout = this._nextLayoutData; if (unfinishedLayout && isFunction(unfinishedLayout.cancel)) { unfinishedLayout.cancel(); } // Compute layout id (let's stay in Float32 range). layoutId = (layoutId % MAX_SAFE_FLOAT32_INTEGER) + 1; var nextLayoutId = layoutId; // Store data for next layout. this._nextLayoutData = { id: nextLayoutId, instant: instant, onFinish: onFinish, cancel: null, }; // Collect layout items (all active grid items). var items = this._items; var layoutItems = []; for (var i = 0; i < items.length; i++) { if (items[i]._isActive) layoutItems.push(items[i]); } // Compute new layout. this._refreshDimensions(); var gridWidth = this._width - this._borderLeft - this._borderRight; var gridHeight = this._height - this._borderTop - this._borderBottom; var layoutSettings = this._settings.layout; var cancelLayout; if (isFunction(layoutSettings)) { cancelLayout = layoutSettings( this, nextLayoutId, layoutItems, gridWidth, gridHeight, this._onLayoutDataReceived ); } else { Grid.defaultPacker.setOptions(layoutSettings); cancelLayout = Grid.defaultPacker.createLayout( this, nextLayoutId, layoutItems, gridWidth, gridHeight, this._onLayoutDataReceived ); } // Store layout cancel method if available. if ( isFunction(cancelLayout) && this._nextLayoutData && this._nextLayoutData.id === nextLayoutId ) { this._nextLayoutData.cancel = cancelLayout; } return this; }; /** * Add new items by providing the elements you wish to add to the instance and * optionally provide the index where you want the items to be inserted into. * All elements that are not already children of the container element will be * automatically appended to the container element. If an element has it's CSS * display property set to "none" it will be marked as inactive during the * initiation process. As long as the item is inactive it will not be part of * the layout, but it will retain it's index. You can activate items at any * point with grid.show() method. This method will automatically call * grid.layout() if one or more of the added elements are visible. If only * hidden items are added no layout will be called. All the new visible items * are positioned without animation during their first layout. * * @public * @param {(HTMLElement|HTMLElement[])} elements * @param {Object} [options] * @param {Number} [options.index=-1] * @param {Boolean} [options.active] * @param {(Boolean|Function|String)} [options.layout=true] * @returns {Item[]} */ Grid.prototype.add = function (elements, options) { if (this._isDestroyed || !elements) return []; var newItems = toArray(elements); if (!newItems.length) return newItems; var opts = options || {}; var layout = opts.layout ? opts.layout : opts.layout === undefined; var items = this._items; var needsLayout = false; var fragment; var element; var item; var i; // Collect all the elements that are not child of the grid element into a // document fragment. for (i = 0; i < newItems.length; i++) { element = newItems[i]; if (element.parentNode !== this._element) { fragment = fragment || document.createDocumentFragment(); fragment.appendChild(element); } } // If we have a fragment, let's append it to the grid element. We could just // not do this and the `new Item()` instantiation would handle this for us, // but this way we can add the elements into the DOM a bit faster. if (fragment) { this._element.appendChild(fragment); } // Map provided elements into new grid items. for (i = 0; i < newItems.length; i++) { element = newItems[i]; item = newItems[i] = new Item(this, element, opts.active); // If the item to be added is active, we need to do a layout. Also, we // need to mark the item with the skipNextAnimation flag to make it // position instantly (without animation) during the next layout. Without // the hack the item would animate to it's new position from the northwest // corner of the grid, which feels a bit buggy (imho). if (item._isActive) { needsLayout = true; item._layout._skipNextAnimation = true; } } // Set up the items' initial dimensions and sort data. This needs to be done // in a separate loop to avoid layout thrashing. for (i = 0; i < newItems.length; i++) { item = newItems[i]; item._refreshDimensions(); item._refreshSortData(); } // Add the new items to the items collection to correct index. arrayInsert(items, newItems, opts.index); // Emit add event. if (this._hasListeners(EVENT_ADD)) { this._emit(EVENT_ADD, newItems.slice(0)); } // If layout is needed. if (needsLayout && layout) { this.layout(layout === INSTANT_LAYOUT, isFunction(layout) ? layout : undefined); } return newItems; }; /** * Remove items from the instance. * * @public * @param {Item[]} items * @param {Object} [options] * @param {Boolean} [options.removeElements=false] * @param {(Boolean|Function|String)} [options.layout=true] * @returns {Item[]} */ Grid.prototype.remove = function (items, options) { if (this._isDestroyed || !items.length) return []; var opts = options || {}; var layout = opts.layout ? opts.layout : opts.layout === undefined; var needsLayout = false; var allItems = this.getItems(); var targetItems = []; var indices = []; var index; var item; var i; // Remove the individual items. for (i = 0; i < items.length; i++) { item = items[i]; if (item._isDestroyed) continue; index = this._items.indexOf(item); if (index === -1) continue; if (item._isActive) needsLayout = true; targetItems.push(item); indices.push(allItems.indexOf(item)); item._destroy(opts.removeElements); this._items.splice(index, 1); } // Emit remove event. if (this._hasListeners(EVENT_REMOVE)) { this._emit(EVENT_REMOVE, targetItems.slice(0), indices); } // If layout is needed. if (needsLayout && layout) { this.layout(layout === INSTANT_LAYOUT, isFunction(layout) ? layout : undefined); } return targetItems; }; /** * Show specific instance items. * * @public * @param {Item[]} items * @param {Object} [options] * @param {Boolean} [options.instant=false] * @param {Boolean} [options.syncWithLayout=true] * @param {Function} [options.onFinish] * @param {(Boolean|Function|String)} [options.layout=true] * @returns {Grid} */ Grid.prototype.show = function (items, options) { if (!this._isDestroyed && items.length) { this._setItemsVisibility(items, true, options); } return this; }; /** * Hide specific instance items. * * @public * @param {Item[]} items * @param {Object} [options] * @param {Boolean} [options.instant=false] * @param {Boolean} [options.syncWithLayout=true] * @param {Function} [options.onFinish] * @param {(Boolean|Function|String)} [options.layout=true] * @returns {Grid} */ Grid.prototype.hide = function (items, options) { if (!this._isDestroyed && items.length) { this._setItemsVisibility(items, false, options); } return this; }; /** * Filter items. Expects at least one argument, a predicate, which should be * either a function or a string. The predicate callback is executed for every * item in the instance. If the return value of the predicate is truthy the * item in question will be shown and otherwise hidden. The predicate callback * receives the item instance as it's argument. If the predicate is a string * it is considered to be a selector and it is checked against every item * element in the instance with the native element.matches() method. All the * matching items will be shown and others hidden. * * @public * @param {(Function|String)} predicate * @param {Object} [options] * @param {Boolean} [options.instant=false] * @param {Boolean} [options.syncWithLayout=true] * @param {FilterCallback} [options.onFinish] * @param {(Boolean|Function|String)} [options.layout=true] * @returns {Grid} */ Grid.prototype.filter = function (predicate, options) { if (this._isDestroyed || !this._items.length) return this; var itemsToShow = []; var itemsToHide = []; var isPredicateString = typeof predicate === STRING_TYPE; var isPredicateFn = isFunction(predicate); var opts = options || {}; var isInstant = opts.instant === true; var syncWithLayout = opts.syncWithLayout; var layout = opts.layout ? opts.layout : opts.layout === undefined; var onFinish = isFunction(opts.onFinish) ? opts.onFinish : null; var tryFinishCounter = -1; var tryFinish = noop; var item; var i; // If we have onFinish callback, let's create proper tryFinish callback. if (onFinish) { tryFinish = function () { ++tryFinishCounter && onFinish(itemsToShow.slice(0), itemsToHide.slice(0)); }; } // Check which items need to be shown and which hidden. if (isPredicateFn || isPredicateString) { for (i = 0; i < this._items.length; i++) { item = this._items[i]; if (isPredicateFn ? predicate(item) : elementMatches(item._element, predicate)) { itemsToShow.push(item); } else { itemsToHide.push(item); } } } // Show items that need to be shown. if (itemsToShow.length) { this.show(itemsToShow, { instant: isInstant, syncWithLayout: syncWithLayout, onFinish: tryFinish, layout: false, }); } else { tryFinish(); } // Hide items that need to be hidden. if (itemsToHide.length) { this.hide(itemsToHide, { instant: isInstant, syncWithLayout: syncWithLayout, onFinish: tryFinish, layout: false, }); } else { tryFinish(); } // If there are any items to filter. if (itemsToShow.length || itemsToHide.length) { // Emit filter event. if (this._hasListeners(EVENT_FILTER)) { this._emit(EVENT_FILTER, itemsToShow.slice(0), itemsToHide.slice(0)); } // If layout is needed. if (layout) { this.layout(layout === INSTANT_LAYOUT, isFunction(layout) ? layout : undefined); } } return this; }; /** * Sort items. There are three ways to sort the items. The first is simply by * providing a function as the comparer which works identically to native * array sort. Alternatively you can sort by the sort data you have provided * in the instance's options. Just provide the sort data key(s) as a string * (separated by space) and the items will be sorted based on the provided * sort data keys. Lastly you have the opportunity to provide a presorted * array of items which will be used to sync the internal items array in the * same order. * * @public * @param {(Function|String|Item[])} comparer * @param {Object} [options] * @param {Boolean} [options.descending=false] * @param {(Boolean|Function|String)} [options.layout=true] * @returns {Grid} */ Grid.prototype.sort = (function () { var sortComparer; var isDescending; var origItems; var indexMap; function defaultComparer(a, b) { var result = 0; var criteriaName; var criteriaOrder; var valA; var valB; // Loop through the list of sort criteria. for (var i = 0; i < sortComparer.length; i++) { // Get the criteria name, which should match an item's sort data key. criteriaName = sortComparer[i][0]; criteriaOrder = sortComparer[i][1]; // Get items' cached sort values for the criteria. If the item has no sort // data let's update the items sort data (this is a lazy load mechanism). valA = (a._sortData ? a : a._refreshSortData())._sortData[criteriaName]; valB = (b._sortData ? b : b._refreshSortData())._sortData[criteriaName]; // Sort the items in descending order if defined so explicitly. Otherwise // sort items in ascending order. if (criteriaOrder === 'desc' || (!criteriaOrder && isDescending)) { result = valB < valA ? -1 : valB > valA ? 1 : 0; } else { result = valA < valB ? -1 : valA > valB ? 1 : 0; } // If we have -1 or 1 as the return value, let's return it immediately. if (result) return result; } // If values are equal let's compare the item indices to make sure we // have a stable sort. Note that this is not necessary in evergreen browsers // because Array.sort() is nowadays stable. However, in order to guarantee // same results in older browsers we need this. if (!result) { if (!indexMap) indexMap = createIndexMap(origItems); result = isDescending ? compareIndexMap(indexMap, b, a) : compareIndexMap(indexMap, a, b); } return result; } function customComparer(a, b) { var result = isDescending ? -sortComparer(a, b) : sortComparer(a, b); if (!result) { if (!indexMap) indexMap = createIndexMap(origItems); result = isDescending ? compareIndexMap(indexMap, b, a) : compareIndexMap(indexMap, a, b); } return result; } return function (comparer, options) { if (this._isDestroyed || this._items.length < 2) return this; var items = this._items; var opts = options || {}; var layout = opts.layout ? opts.layout : opts.layout === undefined; // Setup parent scope data. isDescending = !!opts.descending; origItems = items.slice(0); indexMap = null; // If function is provided do a native array sort. if (isFunction(comparer)) { sortComparer = comparer; items.sort(customComparer); } // Otherwise if we got a string, let's sort by the sort data as provided in // the instance's options. else if (typeof comparer === STRING_TYPE) { sortComparer = comparer .trim() .split(' ') .filter(function (val) { return val; }) .map(function (val) { return val.split(':'); }); items.sort(defaultComparer); } // Otherwise if we got an array, let's assume it's a presorted array of the // items and order the items based on it. Here we blindly trust that the // presorted array consists of the same item instances as the current // `gird._items` array. else if (Array.isArray(comparer)) { items.length = 0; items.push.apply(items, comparer); } // Otherwise let's throw an error. else { sortComparer = isDescending = origItems = indexMap = null; throw new Error('Invalid comparer argument provided.'); } // Emit sort event. if (this._hasListeners(EVENT_SORT)) { this._emit(EVENT_SORT, items.slice(0), origItems); } // If layout is needed. if (layout) { this.layout(layout === INSTANT_LAYOUT, isFunction(layout) ? layout : undefined); } // Reset data (to avoid mem leaks). sortComparer = isDescending = origItems = indexMap = null; return this; }; })(); /** * Move item to another index or in place of another item. * * @public * @param {(HtmlElement|Number|Item)} item * @param {(HtmlElement|Number|Item)} position * @param {Object} [options] * @param {String} [options.action="move"] * - Accepts either "move" or "swap". * - "move" moves the item in place of the other item. * - "swap" swaps the position of the items. * @param {(Boolean|Function|String)} [options.layout=true] * @returns {Grid} */ Grid.prototype.move = function (item, position, options) { if (this._isDestroyed || this._items.length < 2) return this; var items = this._items; var opts = options || {}; var layout = opts.layout ? opts.layout : opts.layout === undefined; var isSwap = opts.action === ACTION_SWAP; var action = isSwap ? ACTION_SWAP : ACTION_MOVE; var fromItem = this.getItem(item); var toItem = this.getItem(position); var fromIndex; var toIndex; // Make sure the items exist and are not the same. if (fromItem && toItem && fromItem !== toItem) { // Get the indices of the items. fromIndex = items.indexOf(fromItem); toIndex = items.indexOf(toItem); // Do the move/swap. if (isSwap) { arraySwap(items, fromIndex, toIndex); } else { arrayMove(items, fromIndex, toIndex); } // Emit move event. if (this._hasListeners(EVENT_MOVE)) { this._emit(EVENT_MOVE, { item: fromItem, fromIndex: fromIndex, toIndex: toIndex, action: action, }); } // If layout is needed. if (layout) { this.layout(layout === INSTANT_LAYOUT, isFunction(layout) ? layout : undefined); } } return this; }; /** * Send item to another Grid instance. * * @public * @param {(HtmlElement|Number|Item)} item * @param {Grid} targetGrid * @param {(HtmlElement|Number|Item)} position * @param {Object} [options] * @param {HTMLElement} [options.appendTo=document.body] * @param {(Boolean|Function|String)} [options.layoutSender=true] * @param {(Boolean|Function|String)} [options.layoutReceiver=true] * @returns {Grid} */ Grid.prototype.send = function (item, targetGrid, position, options) { if (this._isDestroyed || targetGrid._isDestroyed || this === targetGrid) return this; // Make sure we have a valid target item. item = this.getItem(item); if (!item) return this; var opts = options || {}; var container = opts.appendTo || document.body; var layoutSender = opts.layoutSender ? opts.layoutSender : opts.layoutSender === undefined; var layoutReceiver = opts.layoutReceiver ? opts.layoutReceiver : opts.layoutReceiver === undefined; // Start the migration process. item._migrate.start(targetGrid, position, container); // If migration was started successfully and the item is active, let's layout // the grids. if (item._migrate._isActive && item._isActive) { if (layoutSender) { this.layout( layoutSender === INSTANT_LAYOUT, isFunction(layoutSender) ? layoutSender : undefined ); } if (layoutReceiver) { targetGrid.layout( layoutReceiver === INSTANT_LAYOUT, isFunction(layoutReceiver) ? layoutReceiver : undefined ); } } return this; }; /** * Destroy the instance. * * @public * @param {Boolean} [removeElements=false] * @returns {Grid} */ Grid.prototype.destroy = function (removeElements) { if (this._isDestroyed) return this; var container = this._element; var items = this._items.slice(0); var layoutStyles = (this._layout && this._layout.styles) || {}; var i, prop; // Unbind window resize event listener. unbindLayoutOnResize(this); // Destroy items. for (i = 0; i < items.length; i++) items[i]._destroy(removeElements); this._items.length = 0; // Restore container. removeClass(container, this._settings.containerClass); for (prop in layoutStyles) container.style[prop] = ''; // Emit destroy event and unbind all events. this._emit(EVENT_DESTROY); this._emitter.destroy(); // Remove reference from the grid instances collection. delete GRID_INSTANCES[this._id]; // Flag instance as destroyed. this._isDestroyed = true; return this; }; /** * Private prototype methods * ************************* */ /** * Emit a grid event. * * @private * @param {String} event * @param {...*} [arg] */ Grid.prototype._emit = function () { if (this._isDestroyed) return; this._emitter.emit.apply(this._emitter, arguments); }; /** * Check if there are any events listeners for an event. * * @private * @param {String} event * @returns {Boolean} */ Grid.prototype._hasListeners = function (event) { if (this._isDestroyed) return false; return this._emitter.countListeners(event) > 0; }; /** * Update container's width, height and offsets. * * @private */ Grid.prototype._updateBoundingRect = function () { var element = this._element; var rect = element.getBoundingClientRect(); this._width = rect.width; this._height = rect.height; this._left = rect.left; this._top = rect.top; this._right = rect.right; this._bottom = rect.bottom; }; /** * Update container's border sizes. * * @private * @param {Boolean} left * @param {Boolean} right * @param {Boolean} top * @param {Boolean} bottom */ Grid.prototype._updateBorders = function (left, right, top, bottom) { var element = this._element; if (left) this._borderLeft = getStyleAsFloat(element, 'border-left-width'); if (right) this._borderRight = getStyleAsFloat(element, 'border-right-width'); if (top) this._borderTop = getStyleAsFloat(element, 'border-top-width'); if (bottom) this._borderBottom = getStyleAsFloat(element, 'border-bottom-width'); }; /** * Refresh all of container's internal dimensions and offsets. * * @private */ Grid.prototype._refreshDimensions = function () { this._updateBoundingRect(); this._updateBorders(1, 1, 1, 1); this._boxSizing = getStyle(this._element, 'box-sizing'); }; /** * Calculate and apply item positions. * * @private * @param {Object} layout */ Grid.prototype._onLayoutDataReceived = (function () { var itemsToLayout = []; return function (layout) { if (this._isDestroyed || !this._nextLayoutData || this._nextLayoutData.id !== layout.id) return; var grid = this; var instant = this._nextLayoutData.instant; var onFinish = this._nextLayoutData.onFinish; var numItems = layout.items.length; var counter = numItems; var item; var left; var top; var i; // Reset next layout data. this._nextLayoutData = null; if (!this._isLayoutFinished && this._hasListeners(EVENT_LAYOUT_ABORT)) { this._emit(EVENT_LAYOUT_ABORT, this._layout.items.slice(0)); } // Update the layout reference. this._layout = layout; // Update the item positions and collect all items that need to be laid // out. It is critical that we update the item position _before_ the // layoutStart event as the new data might be needed in the callback. itemsToLayout.length = 0; for (i = 0; i < numItems; i++) { item = layout.items[i]; // Make sure we have a matching item. if (!item) { --counter; continue; } // Get the item's new left and top values. left = layout.slots[i * 2]; top = layout.slots[i * 2 + 1]; // Let's skip the layout process if we can. Possibly avoids a lot of DOM // operations which saves us some CPU cycles. if (item._canSkipLayout(left, top)) { --counter; continue; } // Update the item's position. item._left = left; item._top = top; // Only active non-dragged items need to be moved. if (item.isActive() && !item.isDragging()) { itemsToLayout.push(item); } else { --counter; } } // Set layout styles to the grid element. if (layout.styles) { setStyles(this._element, layout.styles); } // layoutStart event is intentionally emitted after the container element's // dimensions are set, because otherwise there would be no hook for reacting // to container dimension changes. if (this._hasListeners(EVENT_LAYOUT_START)) { this._emit(EVENT_LAYOUT_START, layout.items.slice(0), instant === true); // Let's make sure that the current layout process has not been overridden // in the layoutStart event, and if so, let's stop processing the aborted // layout. if (this._layout.id !== layout.id) return; } var tryFinish = function () { if (--counter > 0) return; var hasLayoutChanged = grid._layout.id !== layout.id; var callback = isFunction(instant) ? instant : onFinish; if (!hasLayoutChanged) { grid._isLayoutFinished = true; } if (isFunction(callback)) { callback(layout.items.slice(0), hasLayoutChanged); } if (!hasLayoutChanged && grid._hasListeners(EVENT_LAYOUT_END)) { grid._emit(EVENT_LAYOUT_END, layout.items.slice(0)); } }; if (!itemsToLayout.length) { tryFinish(); return this; } this._isLayoutFinished = false; for (i = 0; i < itemsToLayout.length; i++) { if (this._layout.id !== layout.id) break; itemsToLayout[i]._layout.start(instant === true, tryFinish); } if (this._layout.id === layout.id) { itemsToLayout.length = 0; } return this; }; })(); /** * Show or hide Grid instance's items. * * @private * @param {Item[]} items * @param {Boolean} toVisible * @param {Object} [options] * @param {Boolean} [options.instant=false] * @param {Boolean} [options.syncWithLayout=true] * @param {Function} [options.onFinish] * @param {(Boolean|Function|String)} [options.layout=true] */ Grid.prototype._setItemsVisibility = function (items, toVisible, options) { var grid = this; var targetItems = items.slice(0); var opts = options || {}; var isInstant = opts.instant === true; var callback = opts.onFinish; var layout = opts.layout ? opts.layout : opts.layout === undefined; var counter = targetItems.length; var startEvent = toVisible ? EVENT_SHOW_START : EVENT_HIDE_START; var endEvent = toVisible ? EVENT_SHOW_END : EVENT_HIDE_END; var method = toVisible ? 'show' : 'hide'; var needsLayout = false; var completedItems = []; var hiddenItems = []; var item; var i; // If there are no items call the callback, but don't emit any events. if (!counter) { if (isFunction(callback)) callback(targetItems); return; } // Prepare the items. for (i = 0; i < targetItems.length; i++) { item = targetItems[i]; // If inactive item is shown or active item is hidden we need to do // layout. if ((toVisible && !item._isActive) || (!toVisible && item._isActive)) { needsLayout = true; } // If inactive item is shown we also need to do a little hack to make the // item not animate it's next positioning (layout). item._layout._skipNextAnimation = !!(toVisible && !item._isActive); // If a hidden item is being shown we need to refresh the item's // dimensions. if (toVisible && item._visibility._isHidden) { hiddenItems.push(item); } // Add item to layout or remove it from layout. if (toVisible) { item._addToLayout(); } else { item._removeFromLayout(); } } // Force refresh the dimensions of all hidden items. if (hiddenItems.length) { this.refreshItems(hiddenItems, true); hiddenItems.length = 0; } // Show the items in sync with the next layout. function triggerVisibilityChange() { if (needsLayout && opts.syncWithLayout !== false) { grid.off(EVENT_LAYOUT_START, triggerVisibilityChange); } if (grid._hasListeners(startEvent)) { grid._emit(startEvent, targetItems.slice(0)); } for (i = 0; i < targetItems.length; i++) { // Make sure the item is still in the original grid. There is a chance // that the item starts migrating before tiggerVisibilityChange is called. if (targetItems[i]._gridId !== grid._id) { if (--counter < 1) { if (isFunction(callback)) callback(completedItems.slice(0)); if (grid._hasListeners(endEvent)) grid._emit(endEvent, completedItems.slice(0)); } continue; } targetItems[i]._visibility[method](isInstant, function (interrupted, item) { // If the current item's animation was not interrupted add it to the // completedItems array. if (!interrupted) completedItems.push(item); // If all items have finished their animations call the callback // and emit showEnd/hideEnd event. if (--counter < 1) { if (isFunction(callback)) callback(completedItems.slice(0)); if (grid._hasListeners(endEvent)) grid._emit(endEvent, completedItems.slice(0)); } }); } } // Trigger the visibility change, either async with layout or instantly. if (needsLayout && opts.syncWithLayout !== false) { this.on(EVENT_LAYOUT_START, triggerVisibilityChange); } else { triggerVisibilityChange(); } // Trigger layout if needed. if (needsLayout && layout) { this.layout(layout === INSTANT_LAYOUT, isFunction(layout) ? layout : undefined); } }; /** * Private helpers * *************** */ /** * Merge default settings with user settings. The returned object is a new * object with merged values. The merging is a deep merge meaning that all * objects and arrays within the provided settings objects will be also merged * so that modifying the values of the settings object will have no effect on * the returned object. * * @param {Object} defaultSettings * @param {Object} [userSettings] * @returns {Object} Returns a new object. */ function mergeSettings(defaultSettings, userSettings) { // Create a fresh copy of default settings. var settings = mergeObjects({}, defaultSettings); // Merge user settings to default settings. if (userSettings) { settings = mergeObjects(settings, userSettings); } // Handle visible/hidden styles manually so that the whole object is // overridden instead of the props. if (userSettings && userSettings.visibleStyles) { settings.visibleStyles = userSettings.visibleStyles; } else if (defaultSettings && defaultSettings.visibleStyles) { settings.visibleStyles = defaultSettings.visibleStyles; } if (userSettings && userSettings.hiddenStyles) { settings.hiddenStyles = userSettings.hiddenStyles; } else if (defaultSettings && defaultSettings.hiddenStyles) { settings.hiddenStyles = defaultSettings.hiddenStyles; } return settings; } /** * Merge two objects recursively (deep merge). The source object's properties * are merged to the target object. * * @param {Object} target * - The target object. * @param {Object} source * - The source object. * @returns {Object} Returns the target object. */ function mergeObjects(target, source) { var sourceKeys = Object.keys(source); var length = sourceKeys.length; var isSourceObject; var propName; var i; for (i = 0; i < length; i++) { propName = sourceKeys[i]; isSourceObject = isPlainObject(source[propName]); // If target and source values are both objects, merge the objects and // assign the merged value to the target property. if (isPlainObject(target[propName]) && isSourceObject) { target[propName] = mergeObjects(mergeObjects({}, target[propName]), source[propName]); continue; } // If source's value is object and target's is not let's clone the object as // the target's value. if (isSourceObject) { target[propName] = mergeObjects({}, source[propName]); continue; } // If source's value is an array let's clone the array as the target's // value. if (Array.isArray(source[propName])) { target[propName] = source[propName].slice(0); continue; } // In all other cases let's just directly assign the source's value as the // target's value. target[propName] = source[propName]; } return target; } /** * Collect and return initial items for grid. * * @param {HTMLElement} gridElement * @param {?(HTMLElement[]|NodeList|HtmlCollection|String)} elements * @returns {(HTMLElement[]|NodeList|HtmlCollection)} */ function getInitialGridElements(gridElement, elements) { // If we have a wildcard selector let's return all the children. if (elements === '*') { return gridElement.children; } // If we have some more specific selector, let's filter the elements. if (typeof elements === STRING_TYPE) { var result = []; var children = gridElement.children; for (var i = 0; i < children.length; i++) { if (elementMatches(children[i], elements)) { result.push(children[i]); } } return result; } // If we have an array of elements or a node list. if (Array.isArray(elements) || isNodeList(elements)) { return elements; } // Otherwise just return an empty array. return []; } /** * Bind grid's resize handler to window. * * @param {Grid} grid * @param {(Number|Boolean)} delay */ function bindLayoutOnResize(grid, delay) { if (typeof delay !== NUMBER_TYPE) { delay = delay === true ? 0 : -1; } if (delay >= 0) { grid._resizeHandler = debounce(function () { grid.refreshItems().layout(); }, delay); window.addEventListener('resize', grid._resizeHandler); } } /** * Unbind grid's resize handler from window. * * @param {Grid} grid */ function unbindLayoutOnResize(grid) { if (grid._resizeHandler) { grid._resizeHandler(true); window.removeEventListener('resize', grid._resizeHandler); grid._resizeHandler = null; } } /** * Normalize style declaration object, returns a normalized (new) styles object * (prefixed properties and invalid properties removed). * * @param {Object} styles * @returns {Object} */ function normalizeStyles(styles) { var normalized = {}; var docElemStyle = document.documentElement.style; var prop, prefixedProp; // Normalize visible styles (prefix and remove invalid). for (prop in styles) { if (!styles[prop]) continue; prefixedProp = getPrefixedPropName(docElemStyle, prop); if (!prefixedProp) continue; normalized[prefixedProp] = styles[prop]; } return normalized; } /** * Create index map from items. * * @param {Item[]} items * @returns {Object} */ function createIndexMap(items) { var result = {}; for (var i = 0; i < items.length; i++) { result[items[i]._id] = i; } return result; } /** * Sort comparer function for items' index map. * * @param {Object} indexMap * @param {Item} itemA * @param {Item} itemB * @returns {Number} */ function compareIndexMap(indexMap, itemA, itemB) { var indexA = indexMap[itemA._id]; var indexB = indexMap[itemB._id]; return indexA - indexB; } export default Grid;