UNPKG

grid-list

Version:

Drag and drop library for a two-dimensional resizable and responsive list of items

696 lines (590 loc) 20.9 kB
(function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define([], factory); } else if (typeof exports === 'object') { // Node. Does not work with strict CommonJS, but // only CommonJS-like environments that support module.exports, // like Node. module.exports = factory(); } else { // Browser globals (root is window) root.GridList = factory(); } }(this, function() { var GridList = function(items, options) { /** * A GridList manages the two-dimensional positions from a list of items, * within a virtual matrix. * * The GridList's main function is to convert the item positions from one * grid size to another, maintaining as much of their order as possible. * * The GridList's second function is to handle collisions when moving an item * over another. * * The positioning algorithm places items in columns. Starting from left to * right, going through each column top to bottom. * * The size of an item is expressed using the number of cols and rows it * takes up within the grid (w and h) * * The position of an item is express using the col and row position within * the grid (x and y) * * An item is an object of structure: * { * w: 3, h: 1, * x: 0, y: 1 * } */ this._options = options; for (var k in this.defaults) { if (!this._options.hasOwnProperty(k)) { this._options[k] = this.defaults[k]; } } this.items = items; this._adjustSizeOfItems(); this.generateGrid(); }; GridList.cloneItems = function(items, _items) { /** * Clone items with a deep level of one. Items are not referenced but their * properties are */ var _item, i, k; if (_items === undefined) { _items = []; } for (i = 0; i < items.length; i++) { // XXX: this is good because we don't want to lose item reference, but // maybe we should clear their properties since some might be optional if (!_items[i]) { _items[i] = {}; } for (k in items[i]) { _items[i][k] = items[i][k]; } } return _items; }; GridList.prototype = { defaults: { lanes: 5, direction: 'horizontal' }, /** * Illustates grid as text-based table, using a number identifier for each * item. E.g. * * #| 0 1 2 3 4 5 6 7 8 9 10 11 12 13 * -------------------------------------------- * 0| 00 02 03 04 04 06 08 08 08 12 12 13 14 16 * 1| 01 -- 03 05 05 07 09 10 11 11 -- 13 15 -- * * Warn: Does not work if items don't have a width or height specified * besides their position in the grid. */ toString: function() { var widthOfGrid = this.grid.length, output = '\n #|', border = '\n --', item, i, j; // Render the table header for (i = 0; i < widthOfGrid; i++) { output += ' ' + this._padNumber(i, ' '); border += '---'; }; output += border; // Render table contents row by row, as we go on the y axis for (i = 0; i < this._options.lanes; i++) { output += '\n' + this._padNumber(i, ' ') + '|'; for (j = 0; j < widthOfGrid; j++) { output += ' '; item = this.grid[j][i]; output += item ? this._padNumber(this.items.indexOf(item), '0') : '--'; } }; output += '\n'; return output; }, generateGrid: function() { /** * Build the grid structure from scratch, with the current item positions */ var i; this._resetGrid(); for (i = 0; i < this.items.length; i++) { this._markItemPositionToGrid(this.items[i]); } }, resizeGrid: function(lanes) { var currentColumn = 0; this._options.lanes = lanes; this._adjustSizeOfItems(); this._sortItemsByPosition(); this._resetGrid(); // The items will be sorted based on their index within the this.items array, // that is their "1d position" for (var i = 0; i < this.items.length; i++) { var item = this.items[i], position = this._getItemPosition(item); this._updateItemPosition( item, this.findPositionForItem(item, {x: currentColumn, y: 0})); // New items should never be placed to the left of previous items currentColumn = Math.max(currentColumn, position.x); } this._pullItemsToLeft(); }, findPositionForItem: function(item, start, fixedRow) { /** * This method has two options for the position we want for the item: * - Starting from a certain row/column number and only looking for * positions to its right * - Accepting positions for a certain row number only (use-case: items * being shifted to the left/right as a result of collisions) * * @param {Object<x:Number, y:Number, w:Number, h:Number} item * @param {Object<x:Number, y:Number} start Position from which to start * the search. * @param {Number} [fixedRow] If provided, we're going to try to find a * position for the new item on it. If doesn't fit there, we're going * to put it on the first row. * * @returns {Number[2]} x and y. */ var x, y, position; // Start searching for a position from the horizontal position of the // rightmost item from the grid for (x = start.x; x < this.grid.length; x++) { if (fixedRow !== undefined) { position = [x, fixedRow]; if (this._itemFitsAtPosition(item, position)) { return position; } } else { for (y = start.y; y < this._options.lanes; y++) { position = [x, y]; if (this._itemFitsAtPosition(item, position)) { return position; } } } } // If we've reached this point, we need to start a new column var newCol = this.grid.length, newRow = 0; if (fixedRow !== undefined && this._itemFitsAtPosition(item, [newCol, fixedRow])) { newRow = fixedRow; } return [newCol, newRow]; }, moveItemToPosition: function(item, newPosition) { var position = this._getItemPosition({ x: newPosition[0], y: newPosition[1], w: item.w, h: item.h }); this._updateItemPosition(item, [position.x, position.y]); this._resolveCollisions(item); }, resizeItem: function(item, size) { /** * Resize an item and resolve collisions. * * @param {Object} item A reference to an item that's part of the grid. * @param {Object} size * @param {Number} [size.w=item.w] The new width. * @param {Number} [size.h=item.h] The new height. */ var width = size.w || item.w, height = size.h || item.h; this._updateItemSize(item, width, height); this._resolveCollisions(item); this._pullItemsToLeft(); }, getChangedItems: function(initialItems, idAttribute) { /** * Compare the current items against a previous snapshot and return only * the ones that changed their attributes in the meantime. This includes both * position (x, y) and size (w, h) * * Since both their position and size can change, the items need an * additional identifier attribute to match them with their previous state */ var changedItems = []; for (var i = 0; i < initialItems.length; i++) { var item = this._getItemByAttribute(idAttribute, initialItems[i][idAttribute]); if (item.x !== initialItems[i].x || item.y !== initialItems[i].y || item.w !== initialItems[i].w || item.h !== initialItems[i].h) { changedItems.push(item); } } return changedItems; }, _sortItemsByPosition: function() { this.items.sort(function(item1, item2) { var position1 = this._getItemPosition(item1), position2 = this._getItemPosition(item2); // Try to preserve columns. if (position1.x != position2.x) { return position1.x - position2.x; } if (position1.y != position2.y) { return position1.y - position2.y; } // The items are placed on the same position. return 0; }.bind(this)); }, _adjustSizeOfItems: function() { /** * Some items can have 100% height or 100% width. Those dimmensions are * expressed as 0. We need to ensure a valid width and height for each of * those items as the number of items per lane. */ for (var i = 0; i < this.items.length; i++) { var item = this.items[i]; // This can happen only the first time items are checked. // We need the property to have a value for all the items so that the // `cloneItems` method will merge the properties properly. If we only set // it to the items that need it then the following can happen: // // cloneItems([{id: 1, autoSize: true}, {id: 2}], // [{id: 2}, {id: 1, autoSize: true}]); // // will result in // // [{id: 1, autoSize: true}, {id: 2, autoSize: true}] if (item.autoSize === undefined) { item.autoSize = item.w === 0 || item.h === 0; } if (item.autoSize) { if (this._options.direction === 'horizontal') { item.h = this._options.lanes; } else { item.w = this._options.lanes; } } } }, _resetGrid: function() { this.grid = []; }, _itemFitsAtPosition: function(item, newPosition) { /** * Check that an item wouldn't overlap with another one if placed at a * certain position within the grid */ var position = this._getItemPosition(item), x, y, row; // No coordonate can be negative if (newPosition[0] < 0 || newPosition[1] < 0) { return false; } // Make sure the item isn't larger than the entire grid if (newPosition[1] + position.h > this._options.lanes) { return false; } // Make sure the position doesn't overlap with an already positioned // item. for (x = newPosition[0]; x < newPosition[0] + position.w; x++) { var col = this.grid[x]; // Surely a column that hasn't even been created yet is available if (!col) { continue; } for (y = newPosition[1]; y < newPosition[1] + position.h; y++) { // Any space occupied by an item can continue to be occupied by the // same item. if (col[y] && col[y] !== item) { return false; } } } return true; }, _updateItemPosition: function(item, position) { if (item.x !== null && item.y !== null) { this._deleteItemPositionFromGrid(item); } this._setItemPosition(item, position); this._markItemPositionToGrid(item); }, _updateItemSize: function(item, width, height) { /** * @param {Object} item A reference to a grid item. * @param {Number} width The new width. * @param {Number} height The new height. */ if (item.x !== null && item.y !== null) { this._deleteItemPositionFromGrid(item); } item.w = width; item.h = height; this._markItemPositionToGrid(item); }, _markItemPositionToGrid: function(item) { /** * Mark the grid cells that are occupied by an item. This prevents items * from overlapping in the grid */ var position = this._getItemPosition(item), x, y; // Ensure that the grid has enough columns to accomodate the current item. this._ensureColumns(position.x + position.w); for (x = position.x; x < position.x + position.w; x++) { for (y = position.y; y < position.y + position.h; y++) { this.grid[x][y] = item; } } }, _deleteItemPositionFromGrid: function(item) { var position = this._getItemPosition(item), x, y; for (x = position.x; x < position.x + position.w; x++) { // It can happen to try to remove an item from a position not generated // in the grid, probably when loading a persisted grid of items. No need // to create a column to be able to remove something from it, though if (!this.grid[x]) { continue; } for (y = position.y; y < position.y + position.h; y++) { // Don't clear the cell if it's been occupied by a different widget in // the meantime (e.g. when an item has been moved over this one, and // thus by continuing to clear this item's previous position you would // cancel the first item's move, leaving it without any position even) if (this.grid[x][y] == item) { this.grid[x][y] = null; } } } }, _ensureColumns: function(N) { /** * Ensure that the grid has at least N columns available. */ var i; for (i = 0; i < N; i++) { if (!this.grid[i]) { this.grid.push(new GridCol(this._options.lanes)); } } }, _getItemsCollidingWithItem: function(item) { var collidingItems = []; for (var i = 0; i < this.items.length; i++) { if (item != this.items[i] && this._itemsAreColliding(item, this.items[i])) { collidingItems.push(i); } } return collidingItems; }, _itemsAreColliding: function(item1, item2) { var position1 = this._getItemPosition(item1), position2 = this._getItemPosition(item2); return !(position2.x >= position1.x + position1.w || position2.x + position2.w <= position1.x || position2.y >= position1.y + position1.h || position2.y + position2.h <= position1.y); }, _resolveCollisions: function(item) { if (!this._tryToResolveCollisionsLocally(item)) { this._pullItemsToLeft(item); } this._pullItemsToLeft(); }, _tryToResolveCollisionsLocally: function(item) { /** * Attempt to resolve the collisions after moving a an item over one or more * other items within the grid, by shifting the position of the colliding * items around the moving one. This might result in subsequent collisions, * in which case we will revert all position permutations. To be able to * revert to the initial item positions, we create a virtual grid in the * process */ var collidingItems = this._getItemsCollidingWithItem(item); if (!collidingItems.length) { return true; } var _gridList = new GridList([], this._options), leftOfItem, rightOfItem, aboveOfItem, belowOfItem; GridList.cloneItems(this.items, _gridList.items); _gridList.generateGrid(); for (var i = 0; i < collidingItems.length; i++) { var collidingItem = _gridList.items[collidingItems[i]], collidingPosition = this._getItemPosition(collidingItem); // We use a simple algorithm for moving items around when collisions occur: // In this prioritized order, we try to move a colliding item around the // moving one: // 1. to its left side // 2. above it // 3. under it // 4. to its right side var position = this._getItemPosition(item); leftOfItem = [position.x - collidingPosition.w, collidingPosition.y]; rightOfItem = [position.x + position.w, collidingPosition.y]; aboveOfItem = [collidingPosition.x, position.y - collidingPosition.h]; belowOfItem = [collidingPosition.x, position.y + position.h]; if (_gridList._itemFitsAtPosition(collidingItem, leftOfItem)) { _gridList._updateItemPosition(collidingItem, leftOfItem); } else if (_gridList._itemFitsAtPosition(collidingItem, aboveOfItem)) { _gridList._updateItemPosition(collidingItem, aboveOfItem); } else if (_gridList._itemFitsAtPosition(collidingItem, belowOfItem)) { _gridList._updateItemPosition(collidingItem, belowOfItem); } else if (_gridList._itemFitsAtPosition(collidingItem, rightOfItem)) { _gridList._updateItemPosition(collidingItem, rightOfItem); } else { // Collisions failed, we must use the pullItemsToLeft method to arrange // the other items around this item with fixed position. This is our // plan B for when local collision resolving fails. return false; } } // If we reached this point it means we managed to resolve the collisions // from one single iteration, just by moving the colliding items around. So // we accept this scenario and marge the brached-out grid instance into the // original one GridList.cloneItems(_gridList.items, this.items); this.generateGrid(); return true; }, _pullItemsToLeft: function(fixedItem) { /** * Build the grid from scratch, by using the current item positions and * pulling them as much to the left as possible, removing as space between * them as possible. * * If a "fixed item" is provided, its position will be kept intact and the * rest of the items will be layed around it. */ // Start a fresh grid with the fixed item already placed inside this._sortItemsByPosition(); this._resetGrid(); // Start the grid with the fixed item as the first positioned item if (fixedItem) { var fixedPosition = this._getItemPosition(fixedItem); this._updateItemPosition(fixedItem, [fixedPosition.x, fixedPosition.y]); } for (var i = 0; i < this.items.length; i++) { var item = this.items[i], position = this._getItemPosition(item); // The fixed item keeps its exact position if (fixedItem && item == fixedItem) { continue; } var x = this._findLeftMostPositionForItem(item), newPosition = this.findPositionForItem( item, {x: x, y: 0}, position.y); this._updateItemPosition(item, newPosition); } }, _findLeftMostPositionForItem: function(item) { /** * When pulling items to the left, we need to find the leftmost position for * an item, with two considerations in mind: * - preserving its current row * - preserving the previous horizontal order between items */ var tail = 0, position = this._getItemPosition(item); for (var i = 0; i < this.grid.length; i++) { for (var j = position.y; j < position.y + position.h; j++) { var otherItem = this.grid[i][j]; if (!otherItem) { continue; } var otherPosition = this._getItemPosition(otherItem); if (this.items.indexOf(otherItem) < this.items.indexOf(item)) { tail = otherPosition.x + otherPosition.w; } } } return tail; }, _getItemByAttribute: function(key, value) { for (var i = 0; i < this.items.length; i++) { if (this.items[i][key] === value) { return this.items[i]; } } return null; }, _padNumber: function(nr, prefix) { // Currently works for 2-digit numbers (<100) return nr >= 10 ? nr : prefix + nr; }, _getItemPosition: function(item) { /** * If the direction is vertical we need to rotate the grid 90 deg to the * left. Thus, we simulate the fact that items are being pulled to the top. * * Since the items have widths and heights, if we apply the classic * counter-clockwise 90 deg rotation * * [0 -1] * [1 0] * * then the top left point of an item will become the bottom left point of * the rotated item. To adjust for this, we need to subtract from the y * position the height of the original item - the width of the rotated item. * * However, if we do this then we'll reverse some actions: resizing the * width of an item will stretch the item to the left instead of to the * right; resizing an item that doesn't fit into the grid will push the * items around it instead of going on a new row, etc. * * We found it better to do a vertical flip of the grid after rotating it. * This restores the direction of the actions and greatly simplifies the * transformations. */ if (this._options.direction === 'horizontal') { return item; } else { return { x: item.y, y: item.x, w: item.h, h: item.w }; } }, _setItemPosition: function(item, position) { /** * See _getItemPosition. */ if (this._options.direction === 'horizontal') { item.x = position[0]; item.y = position[1]; } else { // We're supposed to subtract the rotated item's height which is actually // the non-rotated item's width. item.x = position[1]; item.y = position[0]; } } }; var GridCol = function(lanes) { for (var i = 0; i < lanes; i++) { this.push(null); } }; // Extend the Array prototype GridCol.prototype = []; // This module will have direct access to the GridList class return GridList; }));