/* * HGrid - v0.2.10 * A Javascript-based hierarchical grid that can be used to manage and organize files and folders */ (function (global, factory) { if (typeof define === 'function' && define.amd) { // AMD/RequireJS define(['jquery'], factory); } else if (typeof module === 'object') { // CommonJS/Node module.exports = factory(jQuery); } else { // No module system global.HGrid = factory(jQuery); } }(this, function(jQuery) { /** * Provides the main HGrid class and HGrid.Error. * @module HGrid */ this.HGrid = (function($) { 'use strict'; var DEFAULT_INDENT = 20; var ROOT_ID = 'root'; var ITEM = 'item'; var FOLDER = 'folder'; function noop() {} ///////////////////// // Data Structures // ///////////////////// var idCounter = 0; // Ensure unique IDs among trees and leaves function getUID() { return idCounter++; } /** * A tree node. If constructed with no args, the node is * considered a root, * * ``` * var root = new HGrid.Tree(); * root.depth // => 0 * var subtree = new Tree({name: 'A subtree', kind: 'folder'}); * root.add(subtree); * subtree.depth // => 1 * ``` * * @class HGrid.Tree * @constructor * @param {Object} data Data to attach to the tree */ function Tree(data) { if (data === undefined) { // No args passed, it's a root this.data = {}; this.id = ROOT_ID; /** * @attribute depth * @type {Number} */ this.depth = 0; this.dataView = new Slick.Data.DataView({ inlineFilters: true }); } else { this.data = data; this.id = data.id ? data.id : getUID(); // Depth and dataView will be set by parent after being added as a subtree this.depth = null; this.dataView = null; } this.children = []; this.parentID = null; } /** * Construct a new Tree from either an object or an array of data. * * Example input: * ``` * [{name: 'Documents', kind: 'folder', * children: [{name: 'mydoc.txt', type: 'item'}]}, * {name: 'rootfile.txt', kind: 'item'} * ] * ``` * * @method fromObject * @param {Object} data * @param {parent} [parent] Parent item. * */ Tree.fromObject = function(data, parent, args) { args = args || {}; var tree, children, leaf, subtree; // If data is an array, create a new root if (Array.isArray(data)) { tree = new Tree(); children = data; } else { // data is an object, create a subtree children = data.children || []; tree = new Tree(data); tree.depth = parent.depth + 1; tree.dataView = parent.dataView; if (args.collapse) { // TODO: Hardcoded. Change this when _collapsed and _hidden states // are saved on Tree and Leaf objects, and not just on the dataview items tree.data._collapsed = true; } } // Assumes nodes have a `kind` property. If `kind` is "item", create a leaf, // else create a Tree. for (var i = 0, len = children.length; i < len; i++) { var child = children[i]; if (child.kind === ITEM) { leaf = Leaf.fromObject(child, tree, args); tree.add(leaf); } else { subtree = Tree.fromObject(child, tree, args); tree.add(subtree); } } return tree; }; Tree.resetIDCounter = function() { idCounter = 0; }; Tree._getCurrentID = function() { return idCounter; }; /** * Add a component to this node * @method add * @param component Either a Tree or Leaf. * @param {Boolean} [updateDataView] Whether to insert the item into the DataView */ Tree.prototype.add = function(component, updateDataView) { // Set deptth, parent ID, and dataview component.parentID = this.id; component.depth = this.depth + 1; component.dataView = this.dataView; this.children.push(component); if (updateDataView) { this.insertIntoDataView(component); } return this; }; Tree.prototype.empty = function(removeSelf) { if (removeSelf) { // Clear children var item = this.getItem(); this.dataView.deleteItem(item.id); } for(var i=0, child; child = this.children[i]; i++) { child.empty(true); child.children = []; } this.children = []; return this; }; // Remove an object from an array, searching by an attribute value function removeByProperty(arr, attr, value){ var i = arr.length; while(i--){ if(arr[i] && arr[i].hasOwnProperty(attr) && (arguments.length > 2 && arr[i][attr] === value )){ arr.splice(i,1); return true; } } return false; } /** * Remove a child node. * @param {Object} child The child node to remove or an id. */ Tree.prototype.remove = function(child) { var childId = typeof child === 'object' ? child.id : child; var removed = removeByProperty(this.children, 'id', child); if(!removed) { for (var i = 0, node; node = this.children[i]; i++) { removed = node.remove(child); } } else { this.dataView.deleteItem(childId); } }; /** * Get the tree's corresponding item object from the dataview. * @method getItem */ Tree.prototype.getItem = function() { return this.dataView.getItemById(this.id); }; /** * Sort the tree in place, on a key. * @method sort */ Tree.prototype.sort = function(key, asc) { this.children.sort(function(child1, child2) { var val1 = child1.data[key], val2 = child2.data[key]; var sign = asc ? 1 : -1; var ret = (val1 === val2 ? 0 : (val1 > val2 ? 1 : -1)) * sign; if (ret !== 0) { return ret; } return 0; }); for (var i = 0, child; child = this.children[i]; i++) { child.sort(key, asc); } return this; }; // TODO: test me Tree.prototype.sortCmp = function(cmp) { this.children.sort(cmp); for (var i = 0, child; child = this.children[i]; i++) { child.sortCmp(key); } return this; }; /** * Computes the index in the DataView where to insert an item, based on * the item's parentID property. * @private */ function computeAddIdx(item, dataView) { var parent = dataView.getItemById(item.parentID); if (parent) { return dataView.getIdxById(parent.id) + 1; } return 0; } Tree.prototype.insertIntoDataView = function(component) { var data = component.toData(); var idx; if (Array.isArray(data)) { for (var i = 0, len = data.length; i < len; i++) { var datum = data[i]; idx = computeAddIdx(datum, this.dataView); this.dataView.insertItem(idx, datum); } } else { // data is an Object, so component is a leaf idx = computeAddIdx(data, this.dataView); this.dataView.insertItem(idx, data); } return this; }; Tree.prototype.ensureDataView = function(dataView) { if (!dataView) { dataView = this.dataView; } this.dataView = dataView; for (var i = 0, node; node = this.children[i]; i++) { node.ensureDataView(dataView); } return this; }; /** * Update the dataview with this tree's data. This should only be called on * a root node. */ Tree.prototype.updateDataView = function(onlySetItems) { if (!this.dataView) { throw new HGrid.Error('Tree does not have a DataView. updateDataView must be called on a root node.'); } if (!onlySetItems) { this.ensureDataView(); } this.dataView.beginUpdate(); this.dataView.setItems(this.toData()); this.dataView.endUpdate(); return this; }; /** * Convert the tree to SlickGrid-compatible data * * @param {Array} result Memoized result. * @return {Array} Array of SlickGrid data */ Tree.prototype.toData = function(result) { // Add this node's data, unless it's a root var data = result || []; if (this.depth !== 0) { var thisItem = $.extend({}, { id: this.id, parentID: this.parentID, _node: this, depth: this.depth }, this.data); data.push(thisItem); } for (var i = 0, len = this.children.length; i < len; i++) { var child = this.children[i]; child.toData(data); } return data; }; /** * Collapse this and all children nodes, by setting the _collapsed attribute * @method collapse * @param {Boolean} hideSelf Whether to hide this node as well */ Tree.prototype.collapse = function(hideSelf, refresh) { var item; if (!this.isRoot()){ item = this.getItem(); // A node can be collapsed but not hidden. For example, if you click // on a folder, it should collapse and hide all of its contents, but the folder // should still be visible. if (hideSelf) { item._hidden = true; } else { item._collapsed = true; item._hidden = false; } } // Collapse and hide all children for (var i = 0, node; node = this.children[i]; i++) { node.collapse(true); } if (!this.isRoot() && refresh) { this.dataView.updateItem(item.id, item); // need to update the item index } return this; }; /** * Performs breadth-first traversal of the tree, executing a function once * per node. * @method bfTraverse * @param {Function} fun Function to execute for each node * @param {Number} maxDepth Max depth to traverse to, or null. */ Tree.prototype.bfTraverse = function(fun, maxDepth) { var frontier = new Queue(); var next = this; while (next) { if (maxDepth && next.depth > maxDepth) { break; } fun.call(this, next); if (next.children.length) { // enqueue all children for (var i = 0, child; child = next.children[i]; i++){ frontier.enq(child); } } next = frontier.deq(); } return this; }; /** * Collapse all nodes at a certain depth * @method collapseAt * @param {Number} depth The depth to collapse at * @param {Boolean} refresh Whether to refresh the DataView. */ Tree.prototype.collapseAt = function(depth, refresh) { if (depth === 0) { return this.collapse(false, refresh); } this.bfTraverse(function(node) { if (node.depth === depth && node instanceof Tree) { // only collapse trees on the way node.collapse(false, true); // Make sure item is updated } }, depth); if (refresh) { this.dataView.refresh(); } return this; }; Tree.prototype.expandAt = function(depth, refresh) { if (depth === 0) { return this.expand(false, refresh); } this.bfTraverse(function(node) { if (!node.isRoot() && node.depth < depth) { node.expand(false, true); // Make sure item is updated } }, depth); if (refresh) { this.dataView.refresh(); } return this; }; Tree.prototype.isHidden = function() { return this.getItem()._hidden; }; /** * Expand this and all children nodes by setting the item's _collapsed attribute * @method expand */ Tree.prototype.expand = function(notFirst, refresh) { var item; if (!this.isRoot()){ item = this.getItem(); if (!notFirst) { item._collapsed = false; } item._hidden = false; } // Expand all children for (var i = 0, node; node = this.children[i]; i++) { if (!item._collapsed) { // Maintain subtree's collapsed state node.expand(true); } } if (!this.isRoot() && refresh) { this.dataView.updateItem(item.id, item); } return this; }; Tree.prototype.isRoot = function() { return this.depth === 0; }; /** * @method isCollapsed * @return {Boolean} Whether the node is collapsed. */ Tree.prototype.isCollapsed = function() { return Boolean(this.getItem()._collapsed); }; /** * @method getPathToRoot * @param {Array} pathSoFar IDs of any path being passed in. Used by leafs. * @return {Array} Node IDs from current to root * * */ Tree.prototype.getPathToRoot = function(pathSoFar) { var path = []; if(typeof pathSoFar !== 'undefined' && pathSoFar instanceof Array){ path = pathSoFar; } var self = this; var item = self.getItem(); do { path.push(item.id); item = self.dataView.getItemById(item.parentID); } while (typeof item !== 'undefined' && item.parentID !== null); return path; }; /** * Leaf representation * @class HGrid.Leaf * @constructor */ function Leaf(data) { this.data = data; this.id = data.id ? data.id : getUID(); this.parentID = null; // Set by parent this.depth = null; this.children = []; this.dataView = null; // Set by parent } /** * Construct a new Leaf from an object. * @method fromObject * @param obj * @static * @return {Leaf} The constructed Leaf. */ Leaf.fromObject = function(obj, parent, args) { args = args || {}; var leaf = new Leaf(obj); if (parent) { leaf.depth = parent.depth + 1; leaf.parentID = parent.id; leaf.dataView = parent.dataView; } if (args.collapse) { // TODO: Hardcoded. Change this when _collapsed and _hidden states // are saved on Tree and Leaf objects, and not just on the dataview items leaf.data._collapsed = true; } return leaf; }; /** * @method getPathToRoot * @return {Array} path of the leaf item to the root. */ Leaf.prototype.getPathToRoot = function() { var parentID = this.parentID; if(parentID === 'root'){ return [this.id]; }else { var parent = this.dataView.getItemById(this.parentID)._node; return parent.getPathToRoot([this.id]); } }; /** * Get the leaf's corresponding item from the dataview. * @method getItem */ Leaf.prototype.getItem = function() { return this.dataView.getItemById(this.id); }; /** * Collapse this leaf by setting its item's _collapsed property. * @method collapse */ /*jshint unused: false */ Leaf.prototype.collapse = function(hideSelf, refresh) { var item = this.getItem(); item._collapsed = item._hidden = true; return this; }; /** * Expand this leaf by setting its item's _collapse property * @method expand */ Leaf.prototype.expand = function() { var item = this.getItem(); item._collapsed = item._hidden = false; return this; }; Leaf.prototype.remove = noop; /** * Convert the Leaf to SlickGrid data format * @method toData * @param {Array} [result] The memoized result * @return {Object} The leaf an item object. */ Leaf.prototype.toData = function(result) { var item = $.extend({}, { id: this.id, parentID: this.parentID, _node: this, depth: this.depth }, this.data); if (result) { result.push(item); } return item; }; Leaf.prototype.ensureDataView = function(dataView) { if (!dataView) { dataView = this.dataView; } this.dataView = dataView; return this; }; Leaf.prototype.sort = noop; Leaf.prototype.isRoot = function() { return this.depth === 0; }; Leaf.prototype.empty = function() { var item = this.getItem(); this.dataView.deleteItem(item.id); return this; }; // An efficient, lightweight queue implementation, adapted from Queue.js by Steven Morley function Queue() { this.queue = []; this.offset = 0; } Queue.prototype.enq = function(item) { this.queue.push(item); }; Queue.prototype.deq = function() { if (this.queue.length === 0) { return undefined; } // store item at front of queue var item = this.queue[this.offset]; if (++ this.offset * 2 >= this.queue.length) { this.queue = this.queue.slice(this.offset); this.offset = 0; } return item; }; Queue.prototype.isEmpty = function() { return this.queue.length === 0; }; //////////////// // Formatting // //////////////// /** * Sanitize a value to be displayed as HTML. */ function sanitized(value) { return value.replace(/&/g, '&').replace(//g, '>'); } /** * Render a spacer element given an indent value in pixels. */ function makeIndentElem(indent) { return ''; } /** * Adds a span element that indents an item element, given an item. * `item` must have a depth property. * @param {Object} item * @param {String} html The inner HTML * @return {String} The rendered HTML */ function withIndent(item, html, indentWidth) { indentWidth = indentWidth || DEFAULT_INDENT; var indent = (item.depth - 1) * indentWidth; // indenting span var spacer = makeIndentElem(indent); return spacer + html; } /** * Surrounds HTML with a span with class='hg-item-content' and 'data-id' attribute * equal to the item's id * @param {Object} item The item object * @param {string} html The inner HTML * @return {String} The rendered HTML */ function asName(item, html) { var cssClass = item.kind === FOLDER ? HGrid.Html.folderNameClass : HGrid.Html.itemNameClass; var openTag = ''; var closingTag = ''; return [openTag, html, closingTag].join(''); } /** * Render the html for a button, given an item and buttonDef. buttonDef is an * object of the form {text: "My button", cssClass: "btn btn-primary", * action: "download" }} * @class renderButton * @private */ function renderButton(buttonDef) { var cssClass; var tag = buttonDef.tag || 'button'; // For now, buttons are required to have the hg-btn class so that a click // event listener can be attacked to them later if (buttonDef.cssClass) { cssClass = HGrid.Html.buttonClass + ' ' + buttonDef.cssClass; } else { cssClass = HGrid.Html.buttonClass; } var action = buttonDef.action || 'noop'; var attributes = buttonDef.attributes || ''; var data = { action: action, cssClass: cssClass, tag: tag, text: buttonDef.text, attributes: attributes }; var html = tpl('<{{tag}} data-hg-action="{{action}}" ' + 'class="{{cssClass}}" {{attributes}} >{{text}}', data); return html; } function renderButtons(buttonDefs) { var renderedButtons = buttonDefs.map(function(btn) { var html = renderButton(btn); return html; }).join(''); return renderedButtons; } function isLoading(item) { return item._node._load_status === HGrid.LOADING_STARTED; } /** * Microtemplating function. Adapted from Riot.js (MIT License). */ var tpl_fn_cache = {}; var tpl = function(template, data) { /*jshint quotmark:false */ if (!template) { return ''; } tpl_fn_cache[template] = tpl_fn_cache[template] || new Function("_", "return '" + template .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/'/g, "\\'") .replace(/\{\{\s*(\w+)\s*\}\}/g, "'+(_.$1?(_.$1+''):(_.$1===0?0:''))+'") + "'" ); return tpl_fn_cache[template](data); }; HGrid.Html = { // Expand/collapse button expandElem: '', collapseElem: '', // Icons folderIcon: '', fileIcon: '', // Placeholder for error messages. Upload error messages will be interpolated here errorElem: ' ', // CSS Classes buttonClass: 'hg-btn', nameClass: 'hg-name', folderNameClass: 'hg-folder-name', itemNameClass: 'hg-item-name', toggleClass: 'hg-toggle' }; HGrid.HtmlFactories = { // Expand/collapse button expandElem: function(item) { return isLoading(item) ? '' : ''; }, collapseElem: function(item) { return isLoading(item) ? '' : ''; }, }; /////////// // HGrid // /////////// // Formatting helpers public interface HGrid.Fmt = HGrid.Format = { withIndent: withIndent, asName: asName, makeIndentElem: makeIndentElem, sanitized: sanitized, button: renderButton, buttons: renderButtons, tpl: tpl }; // Predefined actions HGrid.Actions = { download: { on: 'click', callback: function(evt, item) { this.options.onClickDownload.call(this, evt, item); } }, // Must quote this since "delete" is a reserved word 'delete': { on: 'click', callback: function(evt, item) { this.options.onClickDelete.call(this, evt, item); } }, upload: { on: 'click', callback: function(evt, item) { this.options.onClickUpload.call(this, evt, item); } }, noop: { on: 'click', callback: noop } }; // Predefined column schemas HGrid.Col = HGrid.Columns = { // Name field schema Name: { id: 'name', name: 'Name', sortkey: 'name', folderView: HGrid.Html.folderIcon + ' {{name}}', itemView: HGrid.Html.fileIcon + ' {{name}}', sortable: true, indent: DEFAULT_INDENT, isName: true, showExpander: function(item, args) { return item.kind === HGrid.FOLDER && (item._node.children.length && item.depth || args.lazyLoad) && !item._processing; } }, // Actions buttons schema ActionButtons: { id: 'actions', name: 'Actions', width: 50, sortable: false, folderView: function() { var buttonDefs = []; if (this.options.uploads) { buttonDefs.push({ text: 'Upload', action: 'upload' }); } if (buttonDefs) { return renderButtons(buttonDefs); } return ''; }, itemView: function() { var buttonDefs = [{ text: 'Download', action: 'download' }, { text: 'Delete', action: 'delete' }]; return renderButtons(buttonDefs); } } }; /** * Default options object * @class defaults */ var defaults = { /** * The data for the grid. * @property data */ data: null, /** * Options passed to jQuery.ajax on every request for additional data. * @property [ajaxOptions] * @type {Object} */ ajaxOptions: {}, /** * Returns the URL where to fetch the contents for a given folder. Enables * lazy-loading of data. * @param {Object} folder The folder data item. * @property {Function} [fetchUrl] */ fetchUrl: null, fetchSuccess: function(data, item) {}, fetchError: function(error, item) {}, fetchStart: function(item) {}, /** * Enable uploads (requires DropZone) * @property [uploads] * @type {Boolean} */ uploads: false, /** * Array of column schemas * @property [columns] */ columns: [HGrid.Columns.Name], /** * @property [width] Width of the grid */ width: 600, /** * Height of the grid div in px or 'auto' (to disable vertical scrolling).* * @property [height] */ height: 300, /** * CSS class applied for a highlighted row. * @property [highlightClass] * @type {String} */ highlightClass: 'hg-row-highlight', /** * Width to indent items (in px)* * @property indent */ indent: DEFAULT_INDENT, /** * Additional options passed to Slick.Grid constructor * See: https://github.com/mleibman/SlickGrid/wiki/Grid-Options * @property [slickgridOptions] */ slickgridOptions: {}, /** * URL to send upload requests to. Can be either a string of a function * that receives a data item. * Example: * uploadUrl: function(item) {return '/upload/' + item.id; } * @property [uploadUrl] */ uploadUrl: null, /** * Array of accepted file types. Can be file extensions or mimetypes. * Example: `['.py', 'application/pdf', 'image/*'] * @property [acceptedFiles] * @type {Array} */ acceptedFiles: null, /** * Max filesize in Mb. * @property [maxFilesize] */ maxFilesize: 256, /** * HTTP method to use for uploading. * Can be either a string or a function that receives the item * to upload to and returns the method name. */ uploadMethod: 'POST', /** * Additional headers to send with upload requests. */ uploadHeaders: {}, /** * Additional options passed to DropZone constructor * See: http://www.dropzonejs.com/ * @property [dropzoneOptions] * @type {Object} */ dropzoneOptions: {}, /** * Callback function executed after an item is clicked. * By default, expand or collapse the item. * @property [onClick] */ /*jshint unused: false */ onClick: function(event, item) {}, onClickDownload: function(event, item, options) { this.downloadItem(item, options); }, onClickDelete: function(event, item, options) { this.deleteFile(item, options); }, onClickUpload: function(event, item, options) { // Open up a filepicker for the folder this.uploadToFolder(item); }, onExpand: function(event, item) {}, onCollapse: function(event, item) {}, /** * Callback executed after an item is added. * @property [onItemAdded] */ onItemAdded: function(item) {}, // Dragging related callbacks onDragover: function(evt, item) {}, onDragenter: function(evt, item) {}, onDragleave: function(evt, item) {}, onDrop: function(event, item) {}, /** * Called when a column is sorted. * @param {Object} event * @param {Object} column The column definition for the sorted column. * @param {Object} args SlickGrid sorting args. */ onSort: function(event, column, args) {}, /** * Called whenever a file is added for uploaded * @param {Object} file The file object. Has gridElement and gridItem bound to it. * @param {Object} item The added item * @param {Object} folder The folder item being uploaded to */ uploadAdded: function(file, item, folder) {}, /** * Called whenever a file gets processed. * @property {Function} [uploadProcessing] */ /*jshint unused: false */ uploadProcessing: function(file, item) { // TODO: display Cancel upload button text? }, /** * Called whenever an upload error occurs * @property [uploadError] * @param {Object} file The HTML file object * @param {String} message Error message * @param {Object} item The placeholder item that was added to the grid for the file. */ /*jshint unused: false */ uploadError: function(file, message, item, folder) { // The row element for the added file is stored on the file object var $rowElem = $(file.gridElement); var msg; if (typeof message !== 'string' && message.error) { msg = message.error; } else { msg = message; } // Show error message in any element within the row // that contains 'data-upload-errormessage' $rowElem.find('[data-upload-errormessage]').each(function(i) { this.textContent = msg; }); return this; }, /** * Called whenever upload progress gets updated. * @property [uploadProgress] * @param {Object} file the file object * @param {Number} progress Percentage (0-100) * @param {Number} bytesSent * @param {The data item element} item */ /*jshint unused: false */ uploadProgress: function(file, progress, bytesSent, item) { // Use the row as a progress bar var $row = $(file.gridElement); $row.width(progress + '%'); }, /** * Called whenever an upload is finished successfully * @property [uploadSuccess] */ /*jshint unused: false */ uploadSuccess: function(file, item, data) {}, /** * Called when an upload completes (whether it is successful or not) * @property [uploadComplete] */ uploadComplete: function(file, item) {}, /** * Called before a file gets uploaded. If `done` is called with a string argument, * An error is thrown with the message. If `done` is called with no arguments, * the file is accepted. * @property [uploadAccept] * @param {Object} file The file object * @param {Object} folder The folder item being uploaded to * @param {Function} done Called to either accept or reject a file. */ uploadAccept: function(file, folder, done) { return done(); }, /** * Called just before an upload request is sent. * @property [uploadSending] */ uploadSending: function(file, item, xhr, formData) {}, /** * Returns the url where to download and item * @param {Object} row The row object * @return {String} The download url */ downloadUrl: function(item) {}, deleteUrl: function(item) {}, deleteMethod: function(item) {}, listeners: [], /** * Additional initialization. Useful for adding listeners. * @property {Function} init */ init: function() {}, // CSS Selector for search input box searchInput: null, /** * Search filter that returns true if an item should be displayed in the grid. * By default, items will be searched by name (case insensitive). * @param {Object} item A data item * @param {String} searchText The current text value in the search input box. * @return {Boolean} Whether or not to display an item. */ searchFilter: function (item, searchText) { return item.name.toLowerCase().indexOf(searchText.toLowerCase()) !== -1; }, /** * Function that determines whether a folder can be uploaded to. */ canUpload: function(folder) { return true; }, /** * Called when a user tries to upload to a folder they don't have permission * to upload to. This is called before adding a file to the upload queue. */ uploadDenied: function(folder) {}, getExpandState: null, /** * Pre-preprocessing function for filenames that are added to the grid. A typical * use case is to strip invalid HTML from filenames. */ preprocessFilename: function(filename) { return filename; } }; HGrid._defaults = defaults; // Expose data structures via the HGrid namespace HGrid.Tree = Tree; HGrid.Leaf = Leaf; HGrid.Queue = Queue; // Constants HGrid.DEFAULT_INDENT = DEFAULT_INDENT; HGrid.ROOT_ID = ROOT_ID; HGrid.FOLDER = FOLDER; HGrid.ITEM = ITEM; /** * Custom Error for HGrid-related errors. * * @class HGrid.Error * @constructor */ HGrid.Error = function(message) { Error.call(this, message); this.name = 'HGrid.Error'; this.message = message || ''; }; HGrid.Error.prototype = Object.create(Error.prototype); /** * Construct an HGrid. * * @class HGrid * @constructor * @param {String} element CSS selector for the grid. * @param {Object} options */ function HGrid(selector, options) { var self = this; self.selector = selector; self.element = $(selector); // Merge defaults with options passed in self.options = $.extend({}, defaults, options); self.grid = null; // Set upon calling _initSlickGrid() self.dropzone = null; // Set upon calling _initDropzone() self.plugins = []; // Registered plugins if (self.options.searchInput) { var $searchInput = $(self.options.searchInput); if ($searchInput.length) { self.searchInput = $searchInput; } else { throw new HGrid.Error('Invalid selector for searchInput.'); } } else { self.searchInput = null; } if (typeof self.options.data === 'string') { // data is a URL, get the data asynchronously self.getFromServer(self.options.data, function(data, error) { self._initData(data); self.init(); } ); } else { // data is an object self._initData(self.options.data); self.init(); } } /** * Collapse all folders * @method collapseAll */ HGrid.prototype.collapseAll = function() { this.tree.collapseAt(1, true); return this; }; /** * Remove a folder's contents from the grid. * @method emptyFolder * @param {Object} item The folder item to empty. * @param {Boolean} [removeFolder] Also remove the folder. */ HGrid.prototype.emptyFolder = function(item, removeFolder) { item = typeof item === 'object' ? item : this.getByID(item); item._node.empty(removeFolder); if (!removeFolder) { this.getDataView().updateItem(item.id, item); } return this; }; /** * Helper for retrieving JSON data using AJAX. * @method getFromServer * @param {String} url * @param {Function} done Callback that receives the JSON data and an * error if there is one. * @return {jQuery xhr} The xhr object returned by jQuery.ajax. */ HGrid.prototype.getFromServer = function(url, done) { var self = this; var ajaxOpts = $.extend({}, { url: url, contentType: 'application/json', dataType: 'json', success: function(json) { done && done.call(self, json); }, error: function(xhr, textStatus, error) { done && done.call(self, null, error, textStatus); } }, self.options.ajaxOptions); return $.ajax(ajaxOpts); }; HGrid.prototype._initData = function(data) { var self = this; if (data) { // Tree.fromObject expects an Array, but `data` might be an array or an // object with `data' property if (Array.isArray(data)) { self.tree = Tree.fromObject(data); } else { self.tree = Tree.fromObject(data.data); } self.tree.updateDataView(); // Sync Tree with its wrapped dataview } else { self.tree = new Tree(); } return self; }; var Dropzone; HGrid.prototype.init = function() { this.setHeight(this.options.height) .setWidth(this.options.width) ._initSlickGrid() ._initDataView(); if (this.options.uploads) { if (typeof module === 'object') { Dropzone = require('dropzone'); } else { Dropzone = window.Dropzone; } if (typeof Dropzone === 'undefined') { throw new HGrid.Error('uploads=true requires DropZone to be loaded'); } this._initDropzone(); } // Attach the listeners last, after this.grid and this.dropzone are set this._initListeners(); // Collapse all top-level folders if lazy-loading if (this.isLazy()) { this.collapseAll(); } this.refreshExpandState(); this.options.init.call(this); return this; }; HGrid.prototype.refreshExpandState = function() { var self = this; if (self.options.getExpandState) { var data = self.getData(); for (var i = 0, item; item= data[i]; i++) { if (self.options.getExpandState.call(self, item)) { self.reloadFolder(item); } } } }; HGrid.prototype.setHeight = function(height) { if (height === 'auto') { this.options.slickgridOptions.autoHeight = true; } else { this.element.css('height', height); } return this; }; // TODO: always update column widths after setting width. HGrid.prototype.setWidth = function(width) { this.element.css('width', width); return this; }; // TODO: test me // HGrid column schmea => SlickGrid Formatter HGrid.prototype.makeFormatter = function(schema) { var self = this, view, html; var folderView = schema.folderView; var itemView = schema.itemView; var showExpander = schema.showExpander; var indentWidth = typeof schema.indent === 'number' ? schema.indent : DEFAULT_INDENT; var formatter = function(row, cell, value, colDef, item) { var rendererArgs = { colDef: colDef, row: row, cell: cell, indent: schema.indent, lazyLoad: self.isLazy() }; view = item.kind === FOLDER ? folderView : itemView; if (typeof view === 'function') { html = view.call(self, item, rendererArgs); // Returns the rendered HTML } else { // Use template html = HGrid.Format.tpl(view, item); } if (schema.isName) { html = asName(item, html); } if (showExpander) { var expander; if (typeof showExpander === 'function' && showExpander(item, rendererArgs)) { expander = item._collapsed ? HGrid.HtmlFactories.expandElem(item) : HGrid.HtmlFactories.collapseElem(item); } else { expander = ''; } html = [expander, html].join(''); } if (schema.indent) { html = withIndent(item, html, indentWidth); } return html; }; return formatter; }; // Hgrid column schemas => Slickgrid columns HGrid.prototype._makeSlickgridColumns = function(colSchemas) { var self = this; var columns = colSchemas.map(function(col) { if (!('formatter' in col)) { // Create the formatter function from the columns definition's // "folderView" and "itemView" properties col.formatter = self.makeFormatter.call(self, col); } if ('text' in col) { // Use 'text' instead of 'name' for column header text col.name = col.text; } if ('cssClass' in col) { col.cssClass = col.cssClass + ' ' + 'hg-cell'; } else { col.cssClass = 'hg-cell'; } return col; }); return columns; }; var requiredSlickgridOptions = { editable: false, asyncEditorLoading: false, enableCellNavigation: false, enableColumnReorder: false, // column reordering requires jquery-ui.sortable forceFitColumns: true, fullWidthRows: true }; /** * Constructs a Slick.Grid and Slick.Data.DataView from the data. * Sets this.grid. * @method _initSlickGrid * @private */ HGrid.prototype._initSlickGrid = function() { var self = this; // Convert column schemas to Slickgrid column definitions var columns = self._makeSlickgridColumns(self.options.columns); var options = $.extend({}, requiredSlickgridOptions, self.options.slickgridOptions); self.grid = new Slick.Grid(self.element.selector, self.tree.dataView, columns, options); return self; }; HGrid.prototype.removeHighlight = function(highlightClass) { var cssClass = highlightClass || this.options.highlightClass; this.element.find('.' + cssClass).removeClass(cssClass); return this; }; /** * Get the row element for an item, given its id. * @method getRowElement * @raises HGrid.Error if item does not exist in the DOM. */ HGrid.prototype.getRowElement = function(id) { if (typeof id === 'object') { id = id.id; } try { // May throw a TypeError if item is not yet rendered. return this.grid.getCellNode(this.getDataView().getRowById(id), 0).parentNode; // Rethrow as an HGrid error } catch (err) { throw new HGrid.Error('Row element is not rendered in the DOM.'); } }; HGrid.prototype.getPathToRoot = function(id) { var node = this.getNodeByID(id); return node.getPathToRoot(); }; HGrid.prototype.addHighlight = function(item, highlightClass) { var cssClass = highlightClass || this.options.highlightClass; this.removeHighlight(); var idToHighlight; if (item && item.kind === FOLDER) { idToHighlight = item.id; } else { idToHighlight = item.parentID; } var $rowElement; try{ $rowElement = $(this.getRowElement(idToHighlight)); } catch (err) { // Element to highlight is not in the DOM return this; } if ($rowElement) { $rowElement.addClass(cssClass); } return this; }; HGrid.prototype.folderContains = function(folderId, itemId) { return this.getPathToRoot(itemId).indexOf(folderId) >= 0; }; /** * SlickGrid events that the grid subscribes to. Mostly just delegates to one * of the callbacks in `options`. * For each funcion, `this` refers to the HGrid object. * @attribute slickEvents */ HGrid.prototype.slickEvents = { 'onClick': function(evt, args) { var item = this.getDataView().getItem(args.row); // Expand/collapse item if (this.canToggle(evt.target)) { this.toggleCollapse(item, evt); } this.options.onClick.call(this, evt, item); return this; }, 'onCellChange': function(evt, args) { this.getDataView().updateItem(args.item.id, args.item); return this; }, 'onMouseLeave': function(evt, args) { this.removeHighlight(); }, 'onSort': function(evt, args) { var col = args.sortCol; // column to sort var key = col.field || col.sortkey; // key to sort on if (!key) { throw new HGrid.Error('Sortable column does not define a `sortkey` to sort on.'); } this.tree.sort(key, args.sortAsc); this.tree.updateDataView(true); this.options.onSort.call(this, evt, col, args); } }; HGrid.prototype.getItemFromEvent = function(evt) { var cell = this.grid.getCellFromEvent(evt); if (cell) { return this.getDataView().getItem(cell.row); } else { return null; } }; HGrid.prototype.uploadToFolder = function(item) { this.currentTarget = item; this.setUploadTarget(item); this.dropzone.hiddenFileInput.click(); }; // TODO: untested HGrid.prototype.downloadItem = function(item) { var url; if (typeof this.options.downloadUrl === 'function') { url = this.options.downloadUrl(item); } else { url = this.options.downloadUrl; } if (url) { window.location = url; } return this; }; /** * Send a delete request to an item's download URL. */ HGrid.prototype.deleteFile = function(item, ajaxOptions) { var self = this; var url, method; // TODO: repetition here url = typeof this.options.deleteUrl === 'function' ? this.options.deleteUrl(item) : this.options.deleteUrl; method = typeof this.options.deleteMethod === 'function' ? this.options.deleteMethod(item) : this.options.deleteMethod; var options = $.extend({}, { url: url, type: method, success: function(data) { // Update parent self.updateItem(self.getByID(item.parentID)); self.removeItem(item.id); } }, self.options.ajaxOptions, ajaxOptions); var promise = null; if (url) { promise = $.ajax(options); } return promise; }; HGrid.prototype.currentTarget = null; // The item to upload to /** * Update the dropzone object's options dynamically. Lazily updates the * upload url, method, maxFilesize, etc. * @method setUploadTarget */ HGrid.prototype.setUploadTarget = function(item) { var self = this; // if upload url or upload method is a function, call it, passing in the target item, // and set dropzone to upload to the result function resolveParam(param) { return typeof param === 'function' ? param.call(self, item) : param; } if (self.currentTarget) { $.when( resolveParam(self.options.uploadHeaders), resolveParam(self.options.uploadUrl), resolveParam(self.options.uploadMethod), resolveParam(self.options.maxFilesize), resolveParam(self.options.acceptedFiles) ).done(function(uploadHeaders, uploadUrl, uploadMethod, maxFilesize, acceptedFiles) { self.dropzone.options.headers = uploadHeaders; self.dropzone.options.url = uploadUrl; self.dropzone.options.method = uploadMethod; self.dropzone.options.maxFilesize = maxFilesize; self.setAcceptedFiles(acceptedFiles); if (self.options.uploadAccept) { // Override dropzone accept callback. Just calls options.uploadAccept with the right params self.dropzone.options.accept = function(file, done) { return self.options.uploadAccept.call(self, file, item, done); }; } }); } }; HGrid.prototype.canUpload = function(item) { return Boolean(item && this.options.canUpload(item)); }; HGrid.prototype.denyUpload = function(targetItem) { // Need to throw an error to prevent dropzone's sequence of callbacks from firing this.options.uploadDenied.call(this, targetItem); throw new HGrid.Error('Upload permission denied.'); }; HGrid.prototype.validateTarget = function(targetItem) { if (!this.canUpload(targetItem)) { return this.denyUpload(targetItem); } else { return targetItem; } }; /** * DropZone events that the grid subscribes to. * For each function, `this` refers to the HGrid object. * These listeners are responsible for any setup that needs to occur before executing * the callbacks in `options`, e.g., adding a new row item to the grid, setting the * current upload target, adding special CSS classes * and passing necessary arguments to the options callbacks. * @attribute dropzoneEvents * @type {Object} */ HGrid.prototype.dropzoneEvents = { drop: function(evt) { this.removeHighlight(); this.validateTarget(this.currentTarget); // update the dropzone options, eg. dropzone.options.url this.setUploadTarget(this.currentTarget); this.options.onDrop.call(this, evt, this.currentTarget); }, dragleave: function(evt) { this.removeHighlight(); var item = this.getItemFromEvent(evt); this.options.onDragleave.call(this, evt, item); }, // Set the current upload target upon dragging a file onto the grid dragenter: function(evt) { var item = this.getItemFromEvent(evt); if (item) { if (item.kind === FOLDER) { this.currentTarget = item; } else { this.currentTarget = this.getByID(item.parentID); } } this.options.onDragenter.call(this, evt, item); }, dragover: function(evt) { var currentTarget = this.currentTarget; var item = this.getItemFromEvent(evt); if(this.canUpload(currentTarget)) { if (currentTarget) { this.addHighlight(currentTarget); } } this.options.onDragover.call(this, evt, item); }, dragend: function(evt) { this.removeHighlight(); }, // When a file is added, set currentTarget (the folder item to upload to) // and bind gridElement (the html element for the added row) and gridItem // (the added item object) to the file object addedfile: function(file) { var currentTarget = this.currentTarget; this.validateTarget(currentTarget); var addedItem; if (this.canUpload(currentTarget)){ // Add a new row addedItem = this.addItem({ name: this.options.preprocessFilename.call(this, file.name), kind: HGrid.ITEM, parentID: currentTarget.id }); var rowElem = this.getRowElement(addedItem.id), $rowElem = $(rowElem); // Save the item data and HTML element on the file object file.gridItem = addedItem; file.gridElement = rowElem; $rowElem.addClass('hg-upload-started'); } this.options.uploadAdded.call(this, file, file.gridItem, currentTarget); return addedItem; }, thumbnail: noop, // Just delegate error function to options.uploadError error: function(file, message) { var $rowElem = $(file.gridElement); $rowElem.addClass('hg-upload-error').removeClass('hg-upload-processing'); // Remove the added row var item = $.extend({}, file.gridItem); this.removeItem(file.gridItem.id); return this.options.uploadError.call(this, file, message, item, this.currentTarget); }, processing: function(file) { $(file.gridElement).addClass('hg-upload-processing'); this.currentTarget._processing = true; this.updateItem(this.currentTarget); this.options.uploadProcessing.call(this, file, file.gridItem, this.currentTarget); return this; }, uploadprogress: function(file, progress, bytesSent) { return this.options.uploadProgress.call(this, file, progress, bytesSent, file.gridItem); }, success: function(file, data) { $(file.gridElement).addClass('hg-upload-success'); return this.options.uploadSuccess.call(this, file, file.gridItem, data); }, complete: function(file) { $(file.gridElement).removeClass('hg-upload-processing'); this.currentTarget._processing = false; this.updateItem(this.currentTarget); return this.options.uploadComplete.call(this, file, file.gridItem); }, sending: function(file, xhr, formData) { return this.options.uploadSending(file, file.gridItem, xhr, formData); } }; /** * Wires up all the event handlers. * @method _initListeners * @private */ HGrid.prototype._initListeners = function() { var self = this, callbackName, fn; // Wire up all the slickgrid events for (callbackName in self.slickEvents) { fn = self.slickEvents[callbackName].bind(self); // make `this` object the grid self.grid[callbackName].subscribe(fn); } if (this.options.uploads) { // Wire up all the dropzone events for (callbackName in self.dropzoneEvents) { fn = self.dropzoneEvents[callbackName].bind(self); self.dropzone.on(callbackName, fn); } } // Attach extra listeners from options.listeners var userCallback = function(evt) { var row = self.getItemFromEvent(evt); return evt.data.listenerObj.callback(evt, row, evt.data.grid); }; // TODO: test me for (var i = 0, listener; listener = this.options.listeners[i]; i++) { self.element.on(listener.on, listener.selector, { listenerObj: listener, grid: self }, userCallback); } this.attachActionListeners(); if (self.searchInput) { self.searchInput.keyup(function (e) { self._searchText = this.value; self.getDataView().refresh(); self.grid.invalidate(); self.grid.render(); }); } }; /** * Attaches event listeners based on the actions defined in HGrid.Actions. * For example, if a "spook" action might be defined like so * * ``` * HGrid.Actions['spook'] = { * on: 'click', * callback: function(evt, row) { * alert('Boo!') * } * }; *``` * and a button is created using HGrid.Format.button * ``` * ... * Hgrid.Format.button(item, {text: 'Spook', action: 'spook'}) * ``` * a "click" event listener will automatically be added to the button with * the defined callback. * */ HGrid.prototype.attachActionListeners = function() { var self = this; // Register any new actions; $.extend(HGrid.Actions, self.options.actions); // This just calls the action's defined callback var actionCallback = function(evt) { var row = self.getItemFromEvent(evt); evt.data.actionObj.callback.call(self, evt, row); }; for (var actionName in HGrid.Actions) { var actionDef = HGrid.Actions[actionName]; this.element.on(actionDef.on, '[data-hg-action="' + actionName + '"]', { actionObj: actionDef }, actionCallback); } return this; }; /** * Filter used by SlickGrid for searching and expanding/collapsing items. * Receives an item and returns true if the item should be displayed in the * grid. * * @class hgFilter * @private * @returns {Boolean} Whether to display the item or not. */ function hgFilter(item, args) { var visible; if (args.grid && args.grid._searchText) { item.depth = 0; // Show search results without indent // Use search filter function visible = args.searchFilter.call(args.grid, item, args.grid._searchText); } else { item.depth = item._node.depth; // Restore indent visible = !item._hidden; // Hide collapsed elements } return visible; } // Expose collapse filter for testing purposes HGrid._hgFilter = hgFilter; /** * Sets up the DataView with the filter function. Must be executed after * initializing the Slick.Grid because the filter function needs access to the * data. * @method _initDataView * @private */ HGrid.prototype._initDataView = function() { var self = this; var dataView = this.getDataView(); dataView.beginUpdate(); dataView.setFilterArgs({ grid: self, searchFilter: self.options.searchFilter }); dataView.setFilter(hgFilter); dataView.endUpdate(); dataView.onRowCountChanged.subscribe(function(event, args) { self.grid.updateRowCount(); self.grid.render(); }); dataView.onRowsChanged.subscribe(function(event, args) { self.grid.invalidateRows(args.rows); self.grid.render(); }); return this; }; var requiredDropzoneOpts = { addRemoveLinks: false, previewTemplate: '
' // just a dummy template because dropzone requires it }; HGrid.prototype.setAcceptedFiles = function(fileTypes) { var acceptedFiles; if (Array.isArray(fileTypes)) { acceptedFiles = fileTypes.join(','); } else { acceptedFiles = fileTypes; } this.dropzone.options.acceptedFiles = acceptedFiles; return this; }; /** * Builds a new DropZone object and attaches it the "dropzone" attribute of * the grid. * @method _initDropZone * @private */ HGrid.prototype._initDropzone = function() { var uploadUrl, uploadMethod, headers, acceptedFiles = null; // If a param is a string, return that, otherwise the param is a function, // so the value will be computed later. function resolveParam(param, fallback){ return (typeof param === 'function' || param == null) ? fallback : param; } uploadUrl = resolveParam(this.options.uploadUrl, '/'); uploadMethod = resolveParam(this.options.uploadMethod, 'POST'); headers = resolveParam(this.options.uploadHeaders, {}); acceptedFiles = resolveParam(this.options.acceptedFiles, null); if (Array.isArray(acceptedFiles)){ acceptedFiles = acceptedFiles.join(','); } // Build up the options object, combining the HGrid options, required options, // and additional options var dropzoneOptions = $.extend({}, { url: uploadUrl, // Dropzone expects comma separated list acceptedFiles: acceptedFiles, maxFilesize: this.options.maxFilesize, method: uploadMethod, headers: headers }, requiredDropzoneOpts, this.options.dropzoneOptions); this.dropzone = new Dropzone(this.selector, dropzoneOptions); return this; }; HGrid.prototype.destroy = function() { this.element.html(''); this.grid.destroy(); if (this.dropzone) { this.dropzone.destroy(); } }; /** * Return the data as an array. * * @method getData * @return {Array} Array of data items in the DataView. */ HGrid.prototype.getData = function() { return this.getDataView().getItems(); }; /** * Get a datum by it's ID. */ HGrid.prototype.getByID = function(id) { var dataView = this.getDataView(); return dataView.getItemById(id); }; /** * Return the grid's underlying DataView. * @method getDataView * @return {Slick.Data.DataView} */ HGrid.prototype.getDataView = function() { return this.grid.getData(); }; HGrid.prototype.getRefreshHints = function (item) { var ignoreBefore = this.getDataView().getRowById(item.id); var hints = { expand: { isFilterNarrowing: false, isFilterExpanding: true, ignoreDiffsBefore: ignoreBefore }, collapse: { isFilterNarrowing: true, isFilterExpanding: false, ignoreDiffsBefore: ignoreBefore } }; return hints; }; HGrid.prototype.isLazy = function() { return Boolean(this.options.fetchUrl); // Assume lazy loading is enabled if fetchUrl is defined }; var LOADING_UNFINISHED = HGrid.LOADING_UNFINISHED = 'lu'; var LOADING_STARTED = HGrid.LOADING_STARTED = 'ls'; var LOADING_FINISHED = HGrid.LOADING_FINISHED = 'lf'; HGrid.prototype.setLoadingStatus = function(item, status) { item._node._load_status = status; this.updateItem(item); }; // TODO: test fetch callbacks HGrid.prototype._lazyLoad = function(item) { var self = this; var url = self.options.fetchUrl(item); if (url !== null) { self.options.fetchStart.call(self, item); self.setLoadingStatus(item, LOADING_STARTED); return self.getFromServer(url, function(newData, error) { if (!error) { self.addData(newData, item.id); self.setLoadingStatus(item, LOADING_FINISHED); self.refreshExpandState(); self.options.fetchSuccess.call(self, newData, item); } else { self.setLoadingStatus(item, LOADING_UNFINISHED); self.options.fetchError.call(self, error, item); throw new HGrid.Error('Could not fetch data from url: "' + url + '". Error: ' + error); } }); } return false; }; /** * Expand an item. Updates the dataview. * @method expandItem * @param {Object} item */ HGrid.prototype.expandItem = function(item, evt) { var self = this; item = typeof item === 'object' ? item : self.getByID(item); var node = self.getNodeByID(item.id); item._node.expand(); var dataview = self.getDataView(); var hints = self.getRefreshHints(item).expand; dataview.setRefreshHints(hints); self.getDataView().updateItem(item.id, item); if (self.isLazy() && (node._load_status === undefined || node._load_status === LOADING_UNFINISHED)) { this._lazyLoad(item); } self.options.onExpand.call(self, evt, item); return self; }; /** * Collapse an item. Updates the dataview. * @method collapseItem * @param {Object} item */ HGrid.prototype.collapseItem = function(item, evt) { item = typeof item === 'object' ? item : this.getByID(item); item._node.collapse(); var dataview = this.getDataView(); var hints = this.getRefreshHints(item).collapse; dataview.setRefreshHints(hints); dataview.updateItem(item.id, item); this.options.onCollapse.call(this, evt, item); return this; }; HGrid.prototype.updateItem = function(item) { return this.getDataView().updateItem(item.id, item); }; HGrid.prototype.isCollapsed = function(item) { return Boolean(item._collapsed); }; HGrid.prototype.canToggle = function(elem) { return $(elem).hasClass(HGrid.Html.toggleClass); }; /** * Add an item to the grid. * @method addItem * @param {Object} item Object with `name`, `kind`, and `parentID`. * If parentID is not specified, the new item is added to the root node. * Example: * `{name: 'New Folder', kind: 'folder', parentID: 123}` * @return {Object} The added item. */ HGrid.prototype.addItem = function(item) { var node, parentNode; // Create a new node for the item if (item.kind === HGrid.FOLDER) { node = new HGrid.Tree(item); } else { node = new HGrid.Leaf(item); } if (item.parentID == null) { parentNode = this.tree; } else { parentNode = this.getNodeByID(item.parentID); } parentNode.add(node, true); var newItem = this.getByID(node.id); this.options.onItemAdded.call(this, newItem); return newItem; }; /** * Add multiple items. * * Only one refresh is made to the grid after adding all the items. * @param {Array} items Array of items with "name", "kind", and "parentID". */ // FIXME: This method is slow, because the DataView's idx:id map needs to be updated // on every insert HGrid.prototype.addItems = function(items) { var self = this; this.batchUpdate(function() { for (var i = 0, len = items.length; i < len; i++) { var item = items[i]; self.addItem(item); } }); return this; }; HGrid.prototype.batchUpdate = function(func) { this.getDataView().beginUpdate(); func.call(this); this.getDataView().endUpdate(); }; /** * Add a new grid column * @method addColumn * Example: * ``` * grid.addColumn({id: 'size', name: 'File Size', field: 'filesize', width: 50}) * ``` * @param {Object} colSpec Column specification. See * https://github.com/mleibman/SlickGrid/wiki/Column-Options */ HGrid.prototype.addColumn = function(colSpec) { var columns = this.grid.getColumns(); columns.push(colSpec); this.grid.setColumns(columns); return this; }; /** * Remove a data item by id. * @method removeItem * @param {Number} id ID of the datum to remove. * @return {Object} The removed item */ HGrid.prototype.removeItem = function(id) { return this.tree.remove(id); }; /** * Return a HGrid.Tree or HGrid.Leaf node given an id. * @param {Number} id * @return {HGrid.Tree} The Tree or Leaf with the id. */ HGrid.prototype.getNodeByID = function(id) { if (id === HGrid.ROOT_ID || id == null) { return this.tree; } var item = this.getByID(id); return item._node; }; /** * Toggle an item's collapsed/expanded state. * @method toggleCollapse * @param {item} item A folder item */ HGrid.prototype.toggleCollapse = function(item, event) { if (item) { if (this.isCollapsed(item)) { this.expandItem(item, event); } else { this.collapseItem(item, event); } } return this; }; /** * Add more hierarchical data. The `data` param takes the same form as the * input data. * @param data Hierarchical data to add * @param {Number} parentID ID of the parent node to add the data to */ HGrid.prototype.addData = function(data, parentID) { var self = this; var tree = this.getNodeByID(parentID); var dataView = this.getDataView(); var toAdd; if (Array.isArray(data)) { toAdd = data; } else { // Data is an object with a `data` property toAdd = data.data; } // Loop in reverse order, so that grid items are inserted into the DOM // in the correct order. self.batchUpdate(function() { for (var i = toAdd.length - 1; i >= 0; i--) { var datum = toAdd[i]; var node; if (datum.kind === HGrid.FOLDER) { var collapse = self.isLazy(); var args = {collapse: collapse}; node = Tree.fromObject(datum, tree, args); } else { node = Leaf.fromObject(datum, tree); } tree.add(node, true); // ensure dataview is updated } }); // self.refreshExpandState(); return this; }; /** * Reset a node's loaded state to false. When the node is expanded again and * lazy loading is enabled, a new xhr request will be sent. */ HGrid.prototype.resetLoadedState = function(folder, status) { var self = this; if (status) { self.setLoadingStatus(folder, status); } else { self.setLoadingStatus(folder, LOADING_UNFINISHED); } return this; }; /** * Reload a folder contents. Will send a request even if lazy loading is enabled * @method reloadFolder */ HGrid.prototype.reloadFolder = function(folder) { this.resetLoadedState(folder); this.emptyFolder(folder); this.collapseItem(folder); this.expandItem(folder); return this; }; HGrid.prototype.render = function() { this.grid.render(); return this; }; HGrid.prototype.invalidate = function () { this.grid.invalidate(); return this; }; HGrid.prototype.refreshData = function() { this.getDataView().refresh(); }; HGrid.prototype.registerPlugin = function(plugin) { this.plugins.unshift(plugin); plugin.init(this); }; HGrid.prototype.unregisterPlugin = function(plugin) { var plugins = this.plugins; for (var i = plugins.length; i >= 0; i--) { if (plugins[i] === plugin) { if (plugins[i].destroy) { plugins[i].destroy(); } plugins.splice(i, 1); break; } } }; $.fn.hgrid = function(options) { this.each(function() { if (!this.id) { // Must have ID because SlickGrid requires a selector throw new HGrid.Error('Element must have an ID if initializing HGrid with jQuery'); } var selector = '#' + this.id; return new HGrid(selector, options); }); }; return HGrid; }).call(this, jQuery); /*! * jquery.event.drag - v 2.2 * Copyright (c) 2010 Three Dub Media - http://threedubmedia.com * Open Source MIT License - http://threedubmedia.com/code/license */ // Created: 2008-06-04 // Updated: 2012-05-21 // REQUIRES: jquery 1.7.x ;(function( $ ){ // add the jquery instance method $.fn.drag = function( str, arg, opts ){ // figure out the event type var type = typeof str == "string" ? str : "", // figure out the event handler... fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null; // fix the event type if ( type.indexOf("drag") !== 0 ) type = "drag"+ type; // were options passed opts = ( str == fn ? arg : opts ) || {}; // trigger or bind event handler return fn ? this.bind( type, opts, fn ) : this.trigger( type ); }; // local refs (increase compression) var $event = $.event, $special = $event.special, // configure the drag special event drag = $special.drag = { // these are the default settings defaults: { which: 1, // mouse button pressed to start drag sequence distance: 0, // distance dragged before dragstart not: ':input', // selector to suppress dragging on target elements handle: null, // selector to match handle target elements relative: false, // true to use "position", false to use "offset" drop: true, // false to suppress drop events, true or selector to allow click: false // false to suppress click events after dragend (no proxy) }, // the key name for stored drag data datakey: "dragdata", // prevent bubbling for better performance noBubble: true, // count bound related events add: function( obj ){ // read the interaction data var data = $.data( this, drag.datakey ), // read any passed options opts = obj.data || {}; // count another realted event data.related += 1; // extend data options bound with this event // don't iterate "opts" in case it is a node $.each( drag.defaults, function( key, def ){ if ( opts[ key ] !== undefined ) data[ key ] = opts[ key ]; }); }, // forget unbound related events remove: function(){ $.data( this, drag.datakey ).related -= 1; }, // configure interaction, capture settings setup: function(){ // check for related events if ( $.data( this, drag.datakey ) ) return; // initialize the drag data with copied defaults var data = $.extend({ related:0 }, drag.defaults ); // store the interaction data $.data( this, drag.datakey, data ); // bind the mousedown event, which starts drag interactions $event.add( this, "touchstart mousedown", drag.init, data ); // prevent image dragging in IE... if ( this.attachEvent ) this.attachEvent("ondragstart", drag.dontstart ); }, // destroy configured interaction teardown: function(){ var data = $.data( this, drag.datakey ) || {}; // check for related events if ( data.related ) return; // remove the stored data $.removeData( this, drag.datakey ); // remove the mousedown event $event.remove( this, "touchstart mousedown", drag.init ); // enable text selection drag.textselect( true ); // un-prevent image dragging in IE... if ( this.detachEvent ) this.detachEvent("ondragstart", drag.dontstart ); }, // initialize the interaction init: function( event ){ // sorry, only one touch at a time if ( drag.touched ) return; // the drag/drop interaction data var dd = event.data, results; // check the which directive if ( event.which != 0 && dd.which > 0 && event.which != dd.which ) return; // check for suppressed selector if ( $( event.target ).is( dd.not ) ) return; // check for handle selector if ( dd.handle && !$( event.target ).closest( dd.handle, event.currentTarget ).length ) return; drag.touched = event.type == 'touchstart' ? this : null; dd.propagates = 1; dd.mousedown = this; dd.interactions = [ drag.interaction( this, dd ) ]; dd.target = event.target; dd.pageX = event.pageX; dd.pageY = event.pageY; dd.dragging = null; // handle draginit event... results = drag.hijack( event, "draginit", dd ); // early cancel if ( !dd.propagates ) return; // flatten the result set results = drag.flatten( results ); // insert new interaction elements if ( results && results.length ){ dd.interactions = []; $.each( results, function(){ dd.interactions.push( drag.interaction( this, dd ) ); }); } // remember how many interactions are propagating dd.propagates = dd.interactions.length; // locate and init the drop targets if ( dd.drop !== false && $special.drop ) $special.drop.handler( event, dd ); // disable text selection drag.textselect( false ); // bind additional events... if ( drag.touched ) $event.add( drag.touched, "touchmove touchend", drag.handler, dd ); else $event.add( document, "mousemove mouseup", drag.handler, dd ); // helps prevent text selection or scrolling if ( !drag.touched || dd.live ) return false; }, // returns an interaction object interaction: function( elem, dd ){ var offset = $( elem )[ dd.relative ? "position" : "offset" ]() || { top:0, left:0 }; return { drag: elem, callback: new drag.callback(), droppable: [], offset: offset }; }, // handle drag-releatd DOM events handler: function( event ){ // read the data before hijacking anything var dd = event.data; // handle various events switch ( event.type ){ // mousemove, check distance, start dragging case !dd.dragging && 'touchmove': event.preventDefault(); case !dd.dragging && 'mousemove': // drag tolerance, x� + y� = distance� if ( Math.pow( event.pageX-dd.pageX, 2 ) + Math.pow( event.pageY-dd.pageY, 2 ) < Math.pow( dd.distance, 2 ) ) break; // distance tolerance not reached event.target = dd.target; // force target from "mousedown" event (fix distance issue) drag.hijack( event, "dragstart", dd ); // trigger "dragstart" if ( dd.propagates ) // "dragstart" not rejected dd.dragging = true; // activate interaction // mousemove, dragging case 'touchmove': event.preventDefault(); case 'mousemove': if ( dd.dragging ){ // trigger "drag" drag.hijack( event, "drag", dd ); if ( dd.propagates ){ // manage drop events if ( dd.drop !== false && $special.drop ) $special.drop.handler( event, dd ); // "dropstart", "dropend" break; // "drag" not rejected, stop } event.type = "mouseup"; // helps "drop" handler behave } // mouseup, stop dragging case 'touchend': case 'mouseup': default: if ( drag.touched ) $event.remove( drag.touched, "touchmove touchend", drag.handler ); // remove touch events else $event.remove( document, "mousemove mouseup", drag.handler ); // remove page events if ( dd.dragging ){ if ( dd.drop !== false && $special.drop ) $special.drop.handler( event, dd ); // "drop" drag.hijack( event, "dragend", dd ); // trigger "dragend" } drag.textselect( true ); // enable text selection // if suppressing click events... if ( dd.click === false && dd.dragging ) $.data( dd.mousedown, "suppress.click", new Date().getTime() + 5 ); dd.dragging = drag.touched = false; // deactivate element break; } }, // re-use event object for custom events hijack: function( event, type, dd, x, elem ){ // not configured if ( !dd ) return; // remember the original event and type var orig = { event:event.originalEvent, type:event.type }, // is the event drag related or drog related? mode = type.indexOf("drop") ? "drag" : "drop", // iteration vars result, i = x || 0, ia, $elems, callback, len = !isNaN( x ) ? x : dd.interactions.length; // modify the event type event.type = type; // remove the original event event.originalEvent = null; // initialize the results dd.results = []; // handle each interacted element do if ( ia = dd.interactions[ i ] ){ // validate the interaction if ( type !== "dragend" && ia.cancelled ) continue; // set the dragdrop properties on the event object callback = drag.properties( event, dd, ia ); // prepare for more results ia.results = []; // handle each element $( elem || ia[ mode ] || dd.droppable ).each(function( p, subject ){ // identify drag or drop targets individually callback.target = subject; // force propagtion of the custom event event.isPropagationStopped = function(){ return false; }; // handle the event result = subject ? $event.dispatch.call( subject, event, callback ) : null; // stop the drag interaction for this element if ( result === false ){ if ( mode == "drag" ){ ia.cancelled = true; dd.propagates -= 1; } if ( type == "drop" ){ ia[ mode ][p] = null; } } // assign any dropinit elements else if ( type == "dropinit" ) ia.droppable.push( drag.element( result ) || subject ); // accept a returned proxy element if ( type == "dragstart" ) ia.proxy = $( drag.element( result ) || ia.drag )[0]; // remember this result ia.results.push( result ); // forget the event result, for recycling delete event.result; // break on cancelled handler if ( type !== "dropinit" ) return result; }); // flatten the results dd.results[ i ] = drag.flatten( ia.results ); // accept a set of valid drop targets if ( type == "dropinit" ) ia.droppable = drag.flatten( ia.droppable ); // locate drop targets if ( type == "dragstart" && !ia.cancelled ) callback.update(); } while ( ++i < len ) // restore the original event & type event.type = orig.type; event.originalEvent = orig.event; // return all handler results return drag.flatten( dd.results ); }, // extend the callback object with drag/drop properties... properties: function( event, dd, ia ){ var obj = ia.callback; // elements obj.drag = ia.drag; obj.proxy = ia.proxy || ia.drag; // starting mouse position obj.startX = dd.pageX; obj.startY = dd.pageY; // current distance dragged obj.deltaX = event.pageX - dd.pageX; obj.deltaY = event.pageY - dd.pageY; // original element position obj.originalX = ia.offset.left; obj.originalY = ia.offset.top; // adjusted element position obj.offsetX = obj.originalX + obj.deltaX; obj.offsetY = obj.originalY + obj.deltaY; // assign the drop targets information obj.drop = drag.flatten( ( ia.drop || [] ).slice() ); obj.available = drag.flatten( ( ia.droppable || [] ).slice() ); return obj; }, // determine is the argument is an element or jquery instance element: function( arg ){ if ( arg && ( arg.jquery || arg.nodeType == 1 ) ) return arg; }, // flatten nested jquery objects and arrays into a single dimension array flatten: function( arr ){ return $.map( arr, function( member ){ return member && member.jquery ? $.makeArray( member ) : member && member.length ? drag.flatten( member ) : member; }); }, // toggles text selection attributes ON (true) or OFF (false) textselect: function( bool ){ $( document )[ bool ? "unbind" : "bind" ]("selectstart", drag.dontstart ) .css("MozUserSelect", bool ? "" : "none" ); // .attr("unselectable", bool ? "off" : "on" ) document.unselectable = bool ? "off" : "on"; }, // suppress "selectstart" and "ondragstart" events dontstart: function(){ return false; }, // a callback instance contructor callback: function(){} }; // callback methods drag.callback.prototype = { update: function(){ if ( $special.drop && this.available.length ) $.each( this.available, function( i ){ $special.drop.locate( this, i ); }); } }; // patch $.event.$dispatch to allow suppressing clicks var $dispatch = $event.dispatch; $event.dispatch = function( event ){ if ( $.data( this, "suppress."+ event.type ) - new Date().getTime() > 0 ){ $.removeData( this, "suppress."+ event.type ); return; } return $dispatch.apply( this, arguments ); }; // event fix hooks for touch events... var touchHooks = $event.fixHooks.touchstart = $event.fixHooks.touchmove = $event.fixHooks.touchend = $event.fixHooks.touchcancel = { props: "clientX clientY pageX pageY screenX screenY".split( " " ), filter: function( event, orig ) { if ( orig ){ var touched = ( orig.touches && orig.touches[0] ) || ( orig.changedTouches && orig.changedTouches[0] ) || null; // iOS webkit: touchstart, touchmove, touchend if ( touched ) $.each( touchHooks.props, function( i, prop ){ event[ prop ] = touched[ prop ]; }); } return event; } }; // share the same special event configuration with related events... $special.draginit = $special.dragstart = $special.dragend = drag; })( jQuery ); /*! * jquery.event.drop - v 2.2 * Copyright (c) 2010 Three Dub Media - http://threedubmedia.com * Open Source MIT License - http://threedubmedia.com/code/license */ // Created: 2008-06-04 // Updated: 2012-05-21 // REQUIRES: jquery 1.7.x, event.drag 2.2 ;(function($){ // secure $ jQuery alias // Events: drop, dropstart, dropend // add the jquery instance method $.fn.drop = function( str, arg, opts ){ // figure out the event type var type = typeof str == "string" ? str : "", // figure out the event handler... fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null; // fix the event type if ( type.indexOf("drop") !== 0 ) type = "drop"+ type; // were options passed opts = ( str == fn ? arg : opts ) || {}; // trigger or bind event handler return fn ? this.bind( type, opts, fn ) : this.trigger( type ); }; // DROP MANAGEMENT UTILITY // returns filtered drop target elements, caches their positions $.drop = function( opts ){ opts = opts || {}; // safely set new options... drop.multi = opts.multi === true ? Infinity : opts.multi === false ? 1 : !isNaN( opts.multi ) ? opts.multi : drop.multi; drop.delay = opts.delay || drop.delay; drop.tolerance = $.isFunction( opts.tolerance ) ? opts.tolerance : opts.tolerance === null ? null : drop.tolerance; drop.mode = opts.mode || drop.mode || 'intersect'; }; // local refs (increase compression) var $event = $.event, $special = $event.special, // configure the drop special event drop = $.event.special.drop = { // these are the default settings multi: 1, // allow multiple drop winners per dragged element delay: 20, // async timeout delay mode: 'overlap', // drop tolerance mode // internal cache targets: [], // the key name for stored drop data datakey: "dropdata", // prevent bubbling for better performance noBubble: true, // count bound related events add: function( obj ){ // read the interaction data var data = $.data( this, drop.datakey ); // count another realted event data.related += 1; }, // forget unbound related events remove: function(){ $.data( this, drop.datakey ).related -= 1; }, // configure the interactions setup: function(){ // check for related events if ( $.data( this, drop.datakey ) ) return; // initialize the drop element data var data = { related: 0, active: [], anyactive: 0, winner: 0, location: {} }; // store the drop data on the element $.data( this, drop.datakey, data ); // store the drop target in internal cache drop.targets.push( this ); }, // destroy the configure interaction teardown: function(){ var data = $.data( this, drop.datakey ) || {}; // check for related events if ( data.related ) return; // remove the stored data $.removeData( this, drop.datakey ); // reference the targeted element var element = this; // remove from the internal cache drop.targets = $.grep( drop.targets, function( target ){ return ( target !== element ); }); }, // shared event handler handler: function( event, dd ){ // local vars var results, $targets; // make sure the right data is available if ( !dd ) return; // handle various events switch ( event.type ){ // draginit, from $.event.special.drag case 'mousedown': // DROPINIT >> case 'touchstart': // DROPINIT >> // collect and assign the drop targets $targets = $( drop.targets ); if ( typeof dd.drop == "string" ) $targets = $targets.filter( dd.drop ); // reset drop data winner properties $targets.each(function(){ var data = $.data( this, drop.datakey ); data.active = []; data.anyactive = 0; data.winner = 0; }); // set available target elements dd.droppable = $targets; // activate drop targets for the initial element being dragged $special.drag.hijack( event, "dropinit", dd ); break; // drag, from $.event.special.drag case 'mousemove': // TOLERATE >> case 'touchmove': // TOLERATE >> drop.event = event; // store the mousemove event if ( !drop.timer ) // monitor drop targets drop.tolerate( dd ); break; // dragend, from $.event.special.drag case 'mouseup': // DROP >> DROPEND >> case 'touchend': // DROP >> DROPEND >> drop.timer = clearTimeout( drop.timer ); // delete timer if ( dd.propagates ){ $special.drag.hijack( event, "drop", dd ); $special.drag.hijack( event, "dropend", dd ); } break; } }, // returns the location positions of an element locate: function( elem, index ){ var data = $.data( elem, drop.datakey ), $elem = $( elem ), posi = $elem.offset() || {}, height = $elem.outerHeight(), width = $elem.outerWidth(), location = { elem: elem, width: width, height: height, top: posi.top, left: posi.left, right: posi.left + width, bottom: posi.top + height }; // drag elements might not have dropdata if ( data ){ data.location = location; data.index = index; data.elem = elem; } return location; }, // test the location positions of an element against another OR an X,Y coord contains: function( target, test ){ // target { location } contains test [x,y] or { location } return ( ( test[0] || test.left ) >= target.left && ( test[0] || test.right ) <= target.right && ( test[1] || test.top ) >= target.top && ( test[1] || test.bottom ) <= target.bottom ); }, // stored tolerance modes modes: { // fn scope: "$.event.special.drop" object // target with mouse wins, else target with most overlap wins 'intersect': function( event, proxy, target ){ return this.contains( target, [ event.pageX, event.pageY ] ) ? // check cursor 1e9 : this.modes.overlap.apply( this, arguments ); // check overlap }, // target with most overlap wins 'overlap': function( event, proxy, target ){ // calculate the area of overlap... return Math.max( 0, Math.min( target.bottom, proxy.bottom ) - Math.max( target.top, proxy.top ) ) * Math.max( 0, Math.min( target.right, proxy.right ) - Math.max( target.left, proxy.left ) ); }, // proxy is completely contained within target bounds 'fit': function( event, proxy, target ){ return this.contains( target, proxy ) ? 1 : 0; }, // center of the proxy is contained within target bounds 'middle': function( event, proxy, target ){ return this.contains( target, [ proxy.left + proxy.width * .5, proxy.top + proxy.height * .5 ] ) ? 1 : 0; } }, // sort drop target cache by by winner (dsc), then index (asc) sort: function( a, b ){ return ( b.winner - a.winner ) || ( a.index - b.index ); }, // async, recursive tolerance execution tolerate: function( dd ){ // declare local refs var i, drp, drg, data, arr, len, elem, // interaction iteration variables x = 0, ia, end = dd.interactions.length, // determine the mouse coords xy = [ drop.event.pageX, drop.event.pageY ], // custom or stored tolerance fn tolerance = drop.tolerance || drop.modes[ drop.mode ]; // go through each passed interaction... do if ( ia = dd.interactions[x] ){ // check valid interaction if ( !ia ) return; // initialize or clear the drop data ia.drop = []; // holds the drop elements arr = []; len = ia.droppable.length; // determine the proxy location, if needed if ( tolerance ) drg = drop.locate( ia.proxy ); // reset the loop i = 0; // loop each stored drop target do if ( elem = ia.droppable[i] ){ data = $.data( elem, drop.datakey ); drp = data.location; if ( !drp ) continue; // find a winner: tolerance function is defined, call it data.winner = tolerance ? tolerance.call( drop, drop.event, drg, drp ) // mouse position is always the fallback : drop.contains( drp, xy ) ? 1 : 0; arr.push( data ); } while ( ++i < len ); // loop // sort the drop targets arr.sort( drop.sort ); // reset the loop i = 0; // loop through all of the targets again do if ( data = arr[ i ] ){ // winners... if ( data.winner && ia.drop.length < drop.multi ){ // new winner... dropstart if ( !data.active[x] && !data.anyactive ){ // check to make sure that this is not prevented if ( $special.drag.hijack( drop.event, "dropstart", dd, x, data.elem )[0] !== false ){ data.active[x] = 1; data.anyactive += 1; } // if false, it is not a winner else data.winner = 0; } // if it is still a winner if ( data.winner ) ia.drop.push( data.elem ); } // losers... else if ( data.active[x] && data.anyactive == 1 ){ // former winner... dropend $special.drag.hijack( drop.event, "dropend", dd, x, data.elem ); data.active[x] = 0; data.anyactive -= 1; } } while ( ++i < len ); // loop } while ( ++x < end ) // loop // check if the mouse is still moving or is idle if ( drop.last && xy[0] == drop.last.pageX && xy[1] == drop.last.pageY ) delete drop.timer; // idle, don't recurse else // recurse drop.timer = setTimeout(function(){ drop.tolerate( dd ); }, drop.delay ); // remember event, to compare idleness drop.last = drop.event; } }; // share the same special event configuration with related events... $special.dropinit = $special.dropstart = $special.dropend = drop; })(jQuery); // confine scope /*** * Contains core SlickGrid classes. * @module Core * @namespace Slick */ (function ($) { // register namespace $.extend(true, window, { "Slick": { "Event": Event, "EventData": EventData, "EventHandler": EventHandler, "Range": Range, "NonDataRow": NonDataItem, "Group": Group, "GroupTotals": GroupTotals, "EditorLock": EditorLock, /*** * A global singleton editor lock. * @class GlobalEditorLock * @static * @constructor */ "GlobalEditorLock": new EditorLock() } }); /*** * An event object for passing data to event handlers and letting them control propagation. *

This is pretty much identical to how W3C and jQuery implement events.

* @class EventData * @constructor */ function EventData() { var isPropagationStopped = false; var isImmediatePropagationStopped = false; /*** * Stops event from propagating up the DOM tree. * @method stopPropagation */ this.stopPropagation = function () { isPropagationStopped = true; }; /*** * Returns whether stopPropagation was called on this event object. * @method isPropagationStopped * @return {Boolean} */ this.isPropagationStopped = function () { return isPropagationStopped; }; /*** * Prevents the rest of the handlers from being executed. * @method stopImmediatePropagation */ this.stopImmediatePropagation = function () { isImmediatePropagationStopped = true; }; /*** * Returns whether stopImmediatePropagation was called on this event object.\ * @method isImmediatePropagationStopped * @return {Boolean} */ this.isImmediatePropagationStopped = function () { return isImmediatePropagationStopped; } } /*** * A simple publisher-subscriber implementation. * @class Event * @constructor */ function Event() { var handlers = []; /*** * Adds an event handler to be called when the event is fired. *

Event handler will receive two arguments - an EventData and the data * object the event was fired with.

* @method subscribe * @param fn {Function} Event handler. */ this.subscribe = function (fn) { handlers.push(fn); }; /*** * Removes an event handler added with subscribe(fn). * @method unsubscribe * @param fn {Function} Event handler to be removed. */ this.unsubscribe = function (fn) { for (var i = handlers.length - 1; i >= 0; i--) { if (handlers[i] === fn) { handlers.splice(i, 1); } } }; /*** * Fires an event notifying all subscribers. * @method notify * @param args {Object} Additional data object to be passed to all handlers. * @param e {EventData} * Optional. * An EventData object to be passed to all handlers. * For DOM events, an existing W3C/jQuery event object can be passed in. * @param scope {Object} * Optional. * The scope ("this") within which the handler will be executed. * If not specified, the scope will be set to the Event instance. */ this.notify = function (args, e, scope) { e = e || new EventData(); scope = scope || this; var returnValue; for (var i = 0; i < handlers.length && !(e.isPropagationStopped() || e.isImmediatePropagationStopped()); i++) { returnValue = handlers[i].call(scope, e, args); } return returnValue; }; } function EventHandler() { var handlers = []; this.subscribe = function (event, handler) { handlers.push({ event: event, handler: handler }); event.subscribe(handler); return this; // allow chaining }; this.unsubscribe = function (event, handler) { var i = handlers.length; while (i--) { if (handlers[i].event === event && handlers[i].handler === handler) { handlers.splice(i, 1); event.unsubscribe(handler); return; } } return this; // allow chaining }; this.unsubscribeAll = function () { var i = handlers.length; while (i--) { handlers[i].event.unsubscribe(handlers[i].handler); } handlers = []; return this; // allow chaining } } /*** * A structure containing a range of cells. * @class Range * @constructor * @param fromRow {Integer} Starting row. * @param fromCell {Integer} Starting cell. * @param toRow {Integer} Optional. Ending row. Defaults to fromRow. * @param toCell {Integer} Optional. Ending cell. Defaults to fromCell. */ function Range(fromRow, fromCell, toRow, toCell) { if (toRow === undefined && toCell === undefined) { toRow = fromRow; toCell = fromCell; } /*** * @property fromRow * @type {Integer} */ this.fromRow = Math.min(fromRow, toRow); /*** * @property fromCell * @type {Integer} */ this.fromCell = Math.min(fromCell, toCell); /*** * @property toRow * @type {Integer} */ this.toRow = Math.max(fromRow, toRow); /*** * @property toCell * @type {Integer} */ this.toCell = Math.max(fromCell, toCell); /*** * Returns whether a range represents a single row. * @method isSingleRow * @return {Boolean} */ this.isSingleRow = function () { return this.fromRow == this.toRow; }; /*** * Returns whether a range represents a single cell. * @method isSingleCell * @return {Boolean} */ this.isSingleCell = function () { return this.fromRow == this.toRow && this.fromCell == this.toCell; }; /*** * Returns whether a range contains a given cell. * @method contains * @param row {Integer} * @param cell {Integer} * @return {Boolean} */ this.contains = function (row, cell) { return row >= this.fromRow && row <= this.toRow && cell >= this.fromCell && cell <= this.toCell; }; /*** * Returns a readable representation of a range. * @method toString * @return {String} */ this.toString = function () { if (this.isSingleCell()) { return "(" + this.fromRow + ":" + this.fromCell + ")"; } else { return "(" + this.fromRow + ":" + this.fromCell + " - " + this.toRow + ":" + this.toCell + ")"; } } } /*** * A base class that all special / non-data rows (like Group and GroupTotals) derive from. * @class NonDataItem * @constructor */ function NonDataItem() { this.__nonDataRow = true; } /*** * Information about a group of rows. * @class Group * @extends Slick.NonDataItem * @constructor */ function Group() { this.__group = true; /** * Grouping level, starting with 0. * @property level * @type {Number} */ this.level = 0; /*** * Number of rows in the group. * @property count * @type {Integer} */ this.count = 0; /*** * Grouping value. * @property value * @type {Object} */ this.value = null; /*** * Formatted display value of the group. * @property title * @type {String} */ this.title = null; /*** * Whether a group is collapsed. * @property collapsed * @type {Boolean} */ this.collapsed = false; /*** * GroupTotals, if any. * @property totals * @type {GroupTotals} */ this.totals = null; /** * Rows that are part of the group. * @property rows * @type {Array} */ this.rows = []; /** * Sub-groups that are part of the group. * @property groups * @type {Array} */ this.groups = null; /** * A unique key used to identify the group. This key can be used in calls to DataView * collapseGroup() or expandGroup(). * @property groupingKey * @type {Object} */ this.groupingKey = null; } Group.prototype = new NonDataItem(); /*** * Compares two Group instances. * @method equals * @return {Boolean} * @param group {Group} Group instance to compare to. */ Group.prototype.equals = function (group) { return this.value === group.value && this.count === group.count && this.collapsed === group.collapsed; }; /*** * Information about group totals. * An instance of GroupTotals will be created for each totals row and passed to the aggregators * so that they can store arbitrary data in it. That data can later be accessed by group totals * formatters during the display. * @class GroupTotals * @extends Slick.NonDataItem * @constructor */ function GroupTotals() { this.__groupTotals = true; /*** * Parent Group. * @param group * @type {Group} */ this.group = null; } GroupTotals.prototype = new NonDataItem(); /*** * A locking helper to track the active edit controller and ensure that only a single controller * can be active at a time. This prevents a whole class of state and validation synchronization * issues. An edit controller (such as SlickGrid) can query if an active edit is in progress * and attempt a commit or cancel before proceeding. * @class EditorLock * @constructor */ function EditorLock() { var activeEditController = null; /*** * Returns true if a specified edit controller is active (has the edit lock). * If the parameter is not specified, returns true if any edit controller is active. * @method isActive * @param editController {EditController} * @return {Boolean} */ this.isActive = function (editController) { return (editController ? activeEditController === editController : activeEditController !== null); }; /*** * Sets the specified edit controller as the active edit controller (acquire edit lock). * If another edit controller is already active, and exception will be thrown. * @method activate * @param editController {EditController} edit controller acquiring the lock */ this.activate = function (editController) { if (editController === activeEditController) { // already activated? return; } if (activeEditController !== null) { throw "SlickGrid.EditorLock.activate: an editController is still active, can't activate another editController"; } if (!editController.commitCurrentEdit) { throw "SlickGrid.EditorLock.activate: editController must implement .commitCurrentEdit()"; } if (!editController.cancelCurrentEdit) { throw "SlickGrid.EditorLock.activate: editController must implement .cancelCurrentEdit()"; } activeEditController = editController; }; /*** * Unsets the specified edit controller as the active edit controller (release edit lock). * If the specified edit controller is not the active one, an exception will be thrown. * @method deactivate * @param editController {EditController} edit controller releasing the lock */ this.deactivate = function (editController) { if (activeEditController !== editController) { throw "SlickGrid.EditorLock.deactivate: specified editController is not the currently active one"; } activeEditController = null; }; /*** * Attempts to commit the current edit by calling "commitCurrentEdit" method on the active edit * controller and returns whether the commit attempt was successful (commit may fail due to validation * errors, etc.). Edit controller's "commitCurrentEdit" must return true if the commit has succeeded * and false otherwise. If no edit controller is active, returns true. * @method commitCurrentEdit * @return {Boolean} */ this.commitCurrentEdit = function () { return (activeEditController ? activeEditController.commitCurrentEdit() : true); }; /*** * Attempts to cancel the current edit by calling "cancelCurrentEdit" method on the active edit * controller and returns whether the edit was successfully cancelled. If no edit controller is * active, returns true. * @method cancelCurrentEdit * @return {Boolean} */ this.cancelCurrentEdit = function cancelCurrentEdit() { return (activeEditController ? activeEditController.cancelCurrentEdit() : true); }; } })(jQuery); (function ($) { $.extend(true, window, { Slick: { Data: { DataView: DataView, Aggregators: { Avg: AvgAggregator, Min: MinAggregator, Max: MaxAggregator, Sum: SumAggregator } } } }); /*** * A sample Model implementation. * Provides a filtered view of the underlying data. * * Relies on the data item having an "id" property uniquely identifying it. */ function DataView(options) { var self = this; var defaults = { groupItemMetadataProvider: null, inlineFilters: false }; // private var idProperty = "id"; // property holding a unique row id var items = []; // data by index var rows = []; // data by row var idxById = {}; // indexes by id var rowsById = null; // rows by id; lazy-calculated var filter = null; // filter function var updated = null; // updated item ids var suspend = false; // suspends the recalculation var sortAsc = true; var fastSortField; var sortComparer; var refreshHints = {}; var prevRefreshHints = {}; var filterArgs; var filteredItems = []; var compiledFilter; var compiledFilterWithCaching; var filterCache = []; // grouping var groupingInfoDefaults = { getter: null, formatter: null, comparer: function(a, b) { return a.value - b.value; }, predefinedValues: [], aggregators: [], aggregateEmpty: false, aggregateCollapsed: false, aggregateChildGroups: false, collapsed: false, displayTotalsRow: true }; var groupingInfos = []; var groups = []; var toggledGroupsByLevel = []; var groupingDelimiter = ':|:'; var pagesize = 0; var pagenum = 0; var totalRows = 0; // events var onRowCountChanged = new Slick.Event(); var onRowsChanged = new Slick.Event(); var onPagingInfoChanged = new Slick.Event(); options = $.extend(true, {}, defaults, options); function beginUpdate() { suspend = true; } function endUpdate() { suspend = false; refresh(); } function setRefreshHints(hints) { refreshHints = hints; } function setFilterArgs(args) { filterArgs = args; } function updateIdxById(startingIndex) { startingIndex = startingIndex || 0; var id; for (var i = startingIndex, l = items.length; i < l; i++) { id = items[i][idProperty]; if (id === undefined) { throw "Each data element must implement a unique 'id' property"; } idxById[id] = i; } } function ensureIdUniqueness() { var id; for (var i = 0, l = items.length; i < l; i++) { id = items[i][idProperty]; if (id === undefined || idxById[id] !== i) { throw "Each data element must implement a unique 'id' property"; } } } function getItems() { return items; } function setItems(data, objectIdProperty) { if (objectIdProperty !== undefined) { idProperty = objectIdProperty; } items = filteredItems = data; idxById = {}; updateIdxById(); ensureIdUniqueness(); refresh(); } function setPagingOptions(args) { if (args.pageSize != undefined) { pagesize = args.pageSize; pagenum = pagesize ? Math.min(pagenum, Math.max(0, Math.ceil(totalRows / pagesize) - 1)) : 0; } if (args.pageNum != undefined) { pagenum = Math.min(args.pageNum, Math.max(0, Math.ceil(totalRows / pagesize) - 1)); } onPagingInfoChanged.notify(getPagingInfo(), null, self); refresh(); } function getPagingInfo() { var totalPages = pagesize ? Math.max(1, Math.ceil(totalRows / pagesize)) : 1; return {pageSize: pagesize, pageNum: pagenum, totalRows: totalRows, totalPages: totalPages}; } function sort(comparer, ascending) { sortAsc = ascending; sortComparer = comparer; fastSortField = null; if (ascending === false) { items.reverse(); } items.sort(comparer); if (ascending === false) { items.reverse(); } idxById = {}; updateIdxById(); refresh(); } /*** * Provides a workaround for the extremely slow sorting in IE. * Does a [lexicographic] sort on a give column by temporarily overriding Object.prototype.toString * to return the value of that field and then doing a native Array.sort(). */ function fastSort(field, ascending) { sortAsc = ascending; fastSortField = field; sortComparer = null; var oldToString = Object.prototype.toString; Object.prototype.toString = (typeof field == "function") ? field : function () { return this[field] }; // an extra reversal for descending sort keeps the sort stable // (assuming a stable native sort implementation, which isn't true in some cases) if (ascending === false) { items.reverse(); } items.sort(); Object.prototype.toString = oldToString; if (ascending === false) { items.reverse(); } idxById = {}; updateIdxById(); refresh(); } function reSort() { if (sortComparer) { sort(sortComparer, sortAsc); } else if (fastSortField) { fastSort(fastSortField, sortAsc); } } function setFilter(filterFn) { filter = filterFn; if (options.inlineFilters) { compiledFilter = compileFilter(); compiledFilterWithCaching = compileFilterWithCaching(); } refresh(); } function getGrouping() { return groupingInfos; } function setGrouping(groupingInfo) { if (!options.groupItemMetadataProvider) { options.groupItemMetadataProvider = new Slick.Data.GroupItemMetadataProvider(); } groups = []; toggledGroupsByLevel = []; groupingInfo = groupingInfo || []; groupingInfos = (groupingInfo instanceof Array) ? groupingInfo : [groupingInfo]; for (var i = 0; i < groupingInfos.length; i++) { var gi = groupingInfos[i] = $.extend(true, {}, groupingInfoDefaults, groupingInfos[i]); gi.getterIsAFn = typeof gi.getter === "function"; // pre-compile accumulator loops gi.compiledAccumulators = []; var idx = gi.aggregators.length; while (idx--) { gi.compiledAccumulators[idx] = compileAccumulatorLoop(gi.aggregators[idx]); } toggledGroupsByLevel[i] = {}; } refresh(); } /** * @deprecated Please use {@link setGrouping}. */ function groupBy(valueGetter, valueFormatter, sortComparer) { if (valueGetter == null) { setGrouping([]); return; } setGrouping({ getter: valueGetter, formatter: valueFormatter, comparer: sortComparer }); } /** * @deprecated Please use {@link setGrouping}. */ function setAggregators(groupAggregators, includeCollapsed) { if (!groupingInfos.length) { throw new Error("At least one grouping must be specified before calling setAggregators()."); } groupingInfos[0].aggregators = groupAggregators; groupingInfos[0].aggregateCollapsed = includeCollapsed; setGrouping(groupingInfos); } function getItemByIdx(i) { return items[i]; } function getIdxById(id) { return idxById[id]; } function ensureRowsByIdCache() { if (!rowsById) { rowsById = {}; for (var i = 0, l = rows.length; i < l; i++) { rowsById[rows[i][idProperty]] = i; } } } function getRowById(id) { ensureRowsByIdCache(); return rowsById[id]; } function getItemById(id) { return items[idxById[id]]; } function mapIdsToRows(idArray) { var rows = []; ensureRowsByIdCache(); for (var i = 0; i < idArray.length; i++) { var row = rowsById[idArray[i]]; if (row != null) { rows[rows.length] = row; } } return rows; } function mapRowsToIds(rowArray) { var ids = []; for (var i = 0; i < rowArray.length; i++) { if (rowArray[i] < rows.length) { ids[ids.length] = rows[rowArray[i]][idProperty]; } } return ids; } function updateItem(id, item) { if (idxById[id] === undefined || id !== item[idProperty]) { throw "Invalid or non-matching id"; } items[idxById[id]] = item; if (!updated) { updated = {}; } updated[id] = true; refresh(); } function insertItem(insertBefore, item) { items.splice(insertBefore, 0, item); updateIdxById(insertBefore); refresh(); } function addItem(item) { items.push(item); updateIdxById(items.length - 1); refresh(); } function deleteItem(id) { var idx = idxById[id]; if (idx === undefined) { throw "Invalid id"; } delete idxById[id]; items.splice(idx, 1); updateIdxById(idx); refresh(); } function getLength() { return rows.length; } function getItem(i) { return rows[i]; } function getItemMetadata(i) { var item = rows[i]; if (item === undefined) { return null; } // overrides for grouping rows if (item.__group) { return options.groupItemMetadataProvider.getGroupRowMetadata(item); } // overrides for totals rows if (item.__groupTotals) { return options.groupItemMetadataProvider.getTotalsRowMetadata(item); } return null; } function expandCollapseAllGroups(level, collapse) { if (level == null) { for (var i = 0; i < groupingInfos.length; i++) { toggledGroupsByLevel[i] = {}; groupingInfos[i].collapsed = collapse; } } else { toggledGroupsByLevel[level] = {}; groupingInfos[level].collapsed = collapse; } refresh(); } /** * @param level {Number} Optional level to collapse. If not specified, applies to all levels. */ function collapseAllGroups(level) { expandCollapseAllGroups(level, true); } /** * @param level {Number} Optional level to expand. If not specified, applies to all levels. */ function expandAllGroups(level) { expandCollapseAllGroups(level, false); } function expandCollapseGroup(level, groupingKey, collapse) { toggledGroupsByLevel[level][groupingKey] = groupingInfos[level].collapsed ^ collapse; refresh(); } /** * @param varArgs Either a Slick.Group's "groupingKey" property, or a * variable argument list of grouping values denoting a unique path to the row. For * example, calling collapseGroup('high', '10%') will collapse the '10%' subgroup of * the 'high' setGrouping. */ function collapseGroup(varArgs) { var args = Array.prototype.slice.call(arguments); var arg0 = args[0]; if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) { expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, true); } else { expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), true); } } /** * @param varArgs Either a Slick.Group's "groupingKey" property, or a * variable argument list of grouping values denoting a unique path to the row. For * example, calling expandGroup('high', '10%') will expand the '10%' subgroup of * the 'high' setGrouping. */ function expandGroup(varArgs) { var args = Array.prototype.slice.call(arguments); var arg0 = args[0]; if (args.length == 1 && arg0.indexOf(groupingDelimiter) != -1) { expandCollapseGroup(arg0.split(groupingDelimiter).length - 1, arg0, false); } else { expandCollapseGroup(args.length - 1, args.join(groupingDelimiter), false); } } function getGroups() { return groups; } function extractGroups(rows, parentGroup) { var group; var val; var groups = []; var groupsByVal = []; var r; var level = parentGroup ? parentGroup.level + 1 : 0; var gi = groupingInfos[level]; for (var i = 0, l = gi.predefinedValues.length; i < l; i++) { val = gi.predefinedValues[i]; group = groupsByVal[val]; if (!group) { group = new Slick.Group(); group.value = val; group.level = level; group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val; groups[groups.length] = group; groupsByVal[val] = group; } } for (var i = 0, l = rows.length; i < l; i++) { r = rows[i]; val = gi.getterIsAFn ? gi.getter(r) : r[gi.getter]; group = groupsByVal[val]; if (!group) { group = new Slick.Group(); group.value = val; group.level = level; group.groupingKey = (parentGroup ? parentGroup.groupingKey + groupingDelimiter : '') + val; groups[groups.length] = group; groupsByVal[val] = group; } group.rows[group.count++] = r; } if (level < groupingInfos.length - 1) { for (var i = 0; i < groups.length; i++) { group = groups[i]; group.groups = extractGroups(group.rows, group); } } groups.sort(groupingInfos[level].comparer); return groups; } // TODO: lazy totals calculation function calculateGroupTotals(group) { // TODO: try moving iterating over groups into compiled accumulator var gi = groupingInfos[group.level]; var isLeafLevel = (group.level == groupingInfos.length); var totals = new Slick.GroupTotals(); var agg, idx = gi.aggregators.length; while (idx--) { agg = gi.aggregators[idx]; agg.init(); gi.compiledAccumulators[idx].call(agg, (!isLeafLevel && gi.aggregateChildGroups) ? group.groups : group.rows); agg.storeResult(totals); } totals.group = group; group.totals = totals; } function calculateTotals(groups, level) { level = level || 0; var gi = groupingInfos[level]; var idx = groups.length, g; while (idx--) { g = groups[idx]; if (g.collapsed && !gi.aggregateCollapsed) { continue; } // Do a depth-first aggregation so that parent setGrouping aggregators can access subgroup totals. if (g.groups) { calculateTotals(g.groups, level + 1); } if (gi.aggregators.length && ( gi.aggregateEmpty || g.rows.length || (g.groups && g.groups.length))) { calculateGroupTotals(g); } } } function finalizeGroups(groups, level) { level = level || 0; var gi = groupingInfos[level]; var groupCollapsed = gi.collapsed; var toggledGroups = toggledGroupsByLevel[level]; var idx = groups.length, g; while (idx--) { g = groups[idx]; g.collapsed = groupCollapsed ^ toggledGroups[g.groupingKey]; g.title = gi.formatter ? gi.formatter(g) : g.value; if (g.groups) { finalizeGroups(g.groups, level + 1); // Let the non-leaf setGrouping rows get garbage-collected. // They may have been used by aggregates that go over all of the descendants, // but at this point they are no longer needed. g.rows = []; } } } function flattenGroupedRows(groups, level) { level = level || 0; var gi = groupingInfos[level]; var groupedRows = [], rows, gl = 0, g; for (var i = 0, l = groups.length; i < l; i++) { g = groups[i]; groupedRows[gl++] = g; if (!g.collapsed) { rows = g.groups ? flattenGroupedRows(g.groups, level + 1) : g.rows; for (var j = 0, jj = rows.length; j < jj; j++) { groupedRows[gl++] = rows[j]; } } if (g.totals && gi.displayTotalsRow && (!g.collapsed || gi.aggregateCollapsed)) { groupedRows[gl++] = g.totals; } } return groupedRows; } function getFunctionInfo(fn) { var fnRegex = /^function[^(]*\(([^)]*)\)\s*{([\s\S]*)}$/; var matches = fn.toString().match(fnRegex); return { params: matches[1].split(","), body: matches[2] }; } function compileAccumulatorLoop(aggregator) { var accumulatorInfo = getFunctionInfo(aggregator.accumulate); var fn = new Function( "_items", "for (var " + accumulatorInfo.params[0] + ", _i=0, _il=_items.length; _i<_il; _i++) {" + accumulatorInfo.params[0] + " = _items[_i]; " + accumulatorInfo.body + "}" ); fn.displayName = fn.name = "compiledAccumulatorLoop"; return fn; } function compileFilter() { var filterInfo = getFunctionInfo(filter); var filterBody = filterInfo.body .replace(/return false[;}]/gi, "{ continue _coreloop; }") .replace(/return true[;}]/gi, "{ _retval[_idx++] = $item$; continue _coreloop; }") .replace(/return ([^;}]+?);/gi, "{ if ($1) { _retval[_idx++] = $item$; }; continue _coreloop; }"); // This preserves the function template code after JS compression, // so that replace() commands still work as expected. var tpl = [ //"function(_items, _args) { ", "var _retval = [], _idx = 0; ", "var $item$, $args$ = _args; ", "_coreloop: ", "for (var _i = 0, _il = _items.length; _i < _il; _i++) { ", "$item$ = _items[_i]; ", "$filter$; ", "} ", "return _retval; " //"}" ].join(""); tpl = tpl.replace(/\$filter\$/gi, filterBody); tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]); tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]); var fn = new Function("_items,_args", tpl); fn.displayName = fn.name = "compiledFilter"; return fn; } function compileFilterWithCaching() { var filterInfo = getFunctionInfo(filter); var filterBody = filterInfo.body .replace(/return false[;}]/gi, "{ continue _coreloop; }") .replace(/return true[;}]/gi, "{ _cache[_i] = true;_retval[_idx++] = $item$; continue _coreloop; }") .replace(/return ([^;}]+?);/gi, "{ if ((_cache[_i] = $1)) { _retval[_idx++] = $item$; }; continue _coreloop; }"); // This preserves the function template code after JS compression, // so that replace() commands still work as expected. var tpl = [ //"function(_items, _args, _cache) { ", "var _retval = [], _idx = 0; ", "var $item$, $args$ = _args; ", "_coreloop: ", "for (var _i = 0, _il = _items.length; _i < _il; _i++) { ", "$item$ = _items[_i]; ", "if (_cache[_i]) { ", "_retval[_idx++] = $item$; ", "continue _coreloop; ", "} ", "$filter$; ", "} ", "return _retval; " //"}" ].join(""); tpl = tpl.replace(/\$filter\$/gi, filterBody); tpl = tpl.replace(/\$item\$/gi, filterInfo.params[0]); tpl = tpl.replace(/\$args\$/gi, filterInfo.params[1]); var fn = new Function("_items,_args,_cache", tpl); fn.displayName = fn.name = "compiledFilterWithCaching"; return fn; } function uncompiledFilter(items, args) { var retval = [], idx = 0; for (var i = 0, ii = items.length; i < ii; i++) { if (filter(items[i], args)) { retval[idx++] = items[i]; } } return retval; } function uncompiledFilterWithCaching(items, args, cache) { var retval = [], idx = 0, item; for (var i = 0, ii = items.length; i < ii; i++) { item = items[i]; if (cache[i]) { retval[idx++] = item; } else if (filter(item, args)) { retval[idx++] = item; cache[i] = true; } } return retval; } function getFilteredAndPagedItems(items) { if (filter) { var batchFilter = options.inlineFilters ? compiledFilter : uncompiledFilter; var batchFilterWithCaching = options.inlineFilters ? compiledFilterWithCaching : uncompiledFilterWithCaching; if (refreshHints.isFilterNarrowing) { filteredItems = batchFilter(filteredItems, filterArgs); } else if (refreshHints.isFilterExpanding) { filteredItems = batchFilterWithCaching(items, filterArgs, filterCache); } else if (!refreshHints.isFilterUnchanged) { filteredItems = batchFilter(items, filterArgs); } } else { // special case: if not filtering and not paging, the resulting // rows collection needs to be a copy so that changes due to sort // can be caught filteredItems = pagesize ? items : items.concat(); } // get the current page var paged; if (pagesize) { if (filteredItems.length < pagenum * pagesize) { pagenum = Math.floor(filteredItems.length / pagesize); } paged = filteredItems.slice(pagesize * pagenum, pagesize * pagenum + pagesize); } else { paged = filteredItems; } return {totalRows: filteredItems.length, rows: paged}; } function getRowDiffs(rows, newRows) { var item, r, eitherIsNonData, diff = []; var from = 0, to = newRows.length; if (refreshHints && refreshHints.ignoreDiffsBefore) { from = Math.max(0, Math.min(newRows.length, refreshHints.ignoreDiffsBefore)); } if (refreshHints && refreshHints.ignoreDiffsAfter) { to = Math.min(newRows.length, Math.max(0, refreshHints.ignoreDiffsAfter)); } for (var i = from, rl = rows.length; i < to; i++) { if (i >= rl) { diff[diff.length] = i; } else { item = newRows[i]; r = rows[i]; if ((groupingInfos.length && (eitherIsNonData = (item.__nonDataRow) || (r.__nonDataRow)) && item.__group !== r.__group || item.__group && !item.equals(r)) || (eitherIsNonData && // no good way to compare totals since they are arbitrary DTOs // deep object comparison is pretty expensive // always considering them 'dirty' seems easier for the time being (item.__groupTotals || r.__groupTotals)) || item[idProperty] != r[idProperty] || (updated && updated[item[idProperty]]) ) { diff[diff.length] = i; } } } return diff; } function recalc(_items) { rowsById = null; if (refreshHints.isFilterNarrowing != prevRefreshHints.isFilterNarrowing || refreshHints.isFilterExpanding != prevRefreshHints.isFilterExpanding) { filterCache = []; } var filteredItems = getFilteredAndPagedItems(_items); totalRows = filteredItems.totalRows; var newRows = filteredItems.rows; groups = []; if (groupingInfos.length) { groups = extractGroups(newRows); if (groups.length) { calculateTotals(groups); finalizeGroups(groups); newRows = flattenGroupedRows(groups); } } var diff = getRowDiffs(rows, newRows); rows = newRows; return diff; } function refresh() { if (suspend) { return; } var countBefore = rows.length; var totalRowsBefore = totalRows; var diff = recalc(items, filter); // pass as direct refs to avoid closure perf hit // if the current page is no longer valid, go to last page and recalc // we suffer a performance penalty here, but the main loop (recalc) remains highly optimized if (pagesize && totalRows < pagenum * pagesize) { pagenum = Math.max(0, Math.ceil(totalRows / pagesize) - 1); diff = recalc(items, filter); } updated = null; prevRefreshHints = refreshHints; refreshHints = {}; if (totalRowsBefore != totalRows) { onPagingInfoChanged.notify(getPagingInfo(), null, self); } if (countBefore != rows.length) { onRowCountChanged.notify({previous: countBefore, current: rows.length}, null, self); } if (diff.length > 0) { onRowsChanged.notify({rows: diff}, null, self); } } function syncGridSelection(grid, preserveHidden) { var self = this; var selectedRowIds = self.mapRowsToIds(grid.getSelectedRows());; var inHandler; function update() { if (selectedRowIds.length > 0) { inHandler = true; var selectedRows = self.mapIdsToRows(selectedRowIds); if (!preserveHidden) { selectedRowIds = self.mapRowsToIds(selectedRows); } grid.setSelectedRows(selectedRows); inHandler = false; } } grid.onSelectedRowsChanged.subscribe(function(e, args) { if (inHandler) { return; } selectedRowIds = self.mapRowsToIds(grid.getSelectedRows()); }); this.onRowsChanged.subscribe(update); this.onRowCountChanged.subscribe(update); } function syncGridCellCssStyles(grid, key) { var hashById; var inHandler; // since this method can be called after the cell styles have been set, // get the existing ones right away storeCellCssStyles(grid.getCellCssStyles(key)); function storeCellCssStyles(hash) { hashById = {}; for (var row in hash) { var id = rows[row][idProperty]; hashById[id] = hash[row]; } } function update() { if (hashById) { inHandler = true; ensureRowsByIdCache(); var newHash = {}; for (var id in hashById) { var row = rowsById[id]; if (row != undefined) { newHash[row] = hashById[id]; } } grid.setCellCssStyles(key, newHash); inHandler = false; } } grid.onCellCssStylesChanged.subscribe(function(e, args) { if (inHandler) { return; } if (key != args.key) { return; } if (args.hash) { storeCellCssStyles(args.hash); } }); this.onRowsChanged.subscribe(update); this.onRowCountChanged.subscribe(update); } return { // methods "beginUpdate": beginUpdate, "endUpdate": endUpdate, "setPagingOptions": setPagingOptions, "getPagingInfo": getPagingInfo, "getItems": getItems, "setItems": setItems, "setFilter": setFilter, "sort": sort, "fastSort": fastSort, "reSort": reSort, "setGrouping": setGrouping, "getGrouping": getGrouping, "groupBy": groupBy, "setAggregators": setAggregators, "collapseAllGroups": collapseAllGroups, "expandAllGroups": expandAllGroups, "collapseGroup": collapseGroup, "expandGroup": expandGroup, "getGroups": getGroups, "getIdxById": getIdxById, "getRowById": getRowById, "getItemById": getItemById, "getItemByIdx": getItemByIdx, "mapRowsToIds": mapRowsToIds, "mapIdsToRows": mapIdsToRows, "setRefreshHints": setRefreshHints, "setFilterArgs": setFilterArgs, "refresh": refresh, "updateItem": updateItem, "insertItem": insertItem, "addItem": addItem, "deleteItem": deleteItem, "syncGridSelection": syncGridSelection, "syncGridCellCssStyles": syncGridCellCssStyles, // data provider methods "getLength": getLength, "getItem": getItem, "getItemMetadata": getItemMetadata, // events "onRowCountChanged": onRowCountChanged, "onRowsChanged": onRowsChanged, "onPagingInfoChanged": onPagingInfoChanged }; } function AvgAggregator(field) { this.field_ = field; this.init = function () { this.count_ = 0; this.nonNullCount_ = 0; this.sum_ = 0; }; this.accumulate = function (item) { var val = item[this.field_]; this.count_++; if (val != null && val !== "" && val !== NaN) { this.nonNullCount_++; this.sum_ += parseFloat(val); } }; this.storeResult = function (groupTotals) { if (!groupTotals.avg) { groupTotals.avg = {}; } if (this.nonNullCount_ != 0) { groupTotals.avg[this.field_] = this.sum_ / this.nonNullCount_; } }; } function MinAggregator(field) { this.field_ = field; this.init = function () { this.min_ = null; }; this.accumulate = function (item) { var val = item[this.field_]; if (val != null && val !== "" && val !== NaN) { if (this.min_ == null || val < this.min_) { this.min_ = val; } } }; this.storeResult = function (groupTotals) { if (!groupTotals.min) { groupTotals.min = {}; } groupTotals.min[this.field_] = this.min_; } } function MaxAggregator(field) { this.field_ = field; this.init = function () { this.max_ = null; }; this.accumulate = function (item) { var val = item[this.field_]; if (val != null && val !== "" && val !== NaN) { if (this.max_ == null || val > this.max_) { this.max_ = val; } } }; this.storeResult = function (groupTotals) { if (!groupTotals.max) { groupTotals.max = {}; } groupTotals.max[this.field_] = this.max_; } } function SumAggregator(field) { this.field_ = field; this.init = function () { this.sum_ = null; }; this.accumulate = function (item) { var val = item[this.field_]; if (val != null && val !== "" && val !== NaN) { this.sum_ += parseFloat(val); } }; this.storeResult = function (groupTotals) { if (!groupTotals.sum) { groupTotals.sum = {}; } groupTotals.sum[this.field_] = this.sum_; } } // TODO: add more built-in aggregators // TODO: merge common aggregators in one to prevent needles iterating })(jQuery); /** * @license * (c) 2009-2012 Michael Leibman * michael{dot}leibman{at}gmail{dot}com * http://github.com/mleibman/slickgrid * * Distributed under MIT license. * All rights reserved. * * SlickGrid v2.1 * * NOTES: * Cell/row DOM manipulations are done directly bypassing jQuery's DOM manipulation methods. * This increases the speed dramatically, but can only be done safely because there are no event handlers * or data associated with any cell/row DOM nodes. Cell editors must make sure they implement .destroy() * and do proper cleanup. */ // make sure required JavaScript modules are loaded if (typeof jQuery === "undefined") { throw "SlickGrid requires jquery module to be loaded"; } if (!jQuery.fn.drag) { throw "SlickGrid requires jquery.event.drag module to be loaded"; } if (typeof Slick === "undefined") { throw "slick.core.js not loaded"; } (function ($) { // Slick.Grid $.extend(true, window, { Slick: { Grid: SlickGrid } }); // shared across all grids on the page var scrollbarDimensions; var maxSupportedCssHeight; // browser's breaking point ////////////////////////////////////////////////////////////////////////////////////////////// // SlickGrid class implementation (available as Slick.Grid) /** * Creates a new instance of the grid. * @class SlickGrid * @constructor * @param {Node} container Container node to create the grid in. * @param {Array,Object} data An array of objects for databinding. * @param {Array} columns An array of column definitions. * @param {Object} options Grid options. **/ function SlickGrid(container, data, columns, options) { // settings var defaults = { explicitInitialization: false, rowHeight: 25, defaultColumnWidth: 80, enableAddRow: false, leaveSpaceForNewRows: false, editable: false, autoEdit: true, enableCellNavigation: true, enableColumnReorder: true, asyncEditorLoading: false, asyncEditorLoadDelay: 100, forceFitColumns: false, enableAsyncPostRender: false, asyncPostRenderDelay: 50, autoHeight: false, editorLock: Slick.GlobalEditorLock, showHeaderRow: false, headerRowHeight: 25, showTopPanel: false, topPanelHeight: 25, formatterFactory: null, editorFactory: null, cellFlashingCssClass: "flashing", selectedCellCssClass: "selected", multiSelect: true, enableTextSelectionOnCells: false, dataItemColumnValueExtractor: null, fullWidthRows: false, multiColumnSort: false, defaultFormatter: defaultFormatter, forceSyncScrolling: false, // BJG: Modified from original Slickgrid for OSF addExtraRowsAtEnd: 0 }; var columnDefaults = { name: "", resizable: true, sortable: false, minWidth: 30, rerenderOnResize: false, headerCssClass: null, defaultSortAsc: true, focusable: true, selectable: true }; // scroller var th; // virtual height var h; // real scrollable height var ph; // page height var n; // number of pages var cj; // "jumpiness" coefficient var page = 0; // current page var offset = 0; // current page offset var vScrollDir = 1; // private var initialized = false; var $container; var uid = "slickgrid_" + Math.round(1000000 * Math.random()); var self = this; var $focusSink, $focusSink2; var $headerScroller; var $headers; var $headerRow, $headerRowScroller, $headerRowSpacer; var $topPanelScroller; var $topPanel; var $viewport; var $canvas; var $style; var $boundAncestors; var stylesheet, columnCssRulesL, columnCssRulesR; var viewportH, viewportW; var canvasWidth; var viewportHasHScroll, viewportHasVScroll; var headerColumnWidthDiff = 0, headerColumnHeightDiff = 0, // border+padding cellWidthDiff = 0, cellHeightDiff = 0; var absoluteColumnMinWidth; var numberOfRows = 0; var tabbingDirection = 1; var activePosX; var activeRow, activeCell; var activeCellNode = null; var currentEditor = null; var serializedEditorValue; var editController; var rowsCache = {}; var renderedRows = 0; var numVisibleRows; var prevScrollTop = 0; var scrollTop = 0; var lastRenderedScrollTop = 0; var lastRenderedScrollLeft = 0; var prevScrollLeft = 0; var scrollLeft = 0; var selectionModel; var selectedRows = []; var plugins = []; var cellCssClasses = {}; var columnsById = {}; var sortColumns = []; var columnPosLeft = []; var columnPosRight = []; // async call handles var h_editorLoader = null; var h_render = null; var h_postrender = null; var postProcessedRows = {}; var postProcessToRow = null; var postProcessFromRow = null; // perf counters var counter_rows_rendered = 0; var counter_rows_removed = 0; ////////////////////////////////////////////////////////////////////////////////////////////// // Initialization function init() { $container = $(container); if ($container.length < 1) { throw new Error("SlickGrid requires a valid container, " + container + " does not exist in the DOM."); } // calculate these only once and share between grid instances maxSupportedCssHeight = maxSupportedCssHeight || getMaxSupportedCssHeight(); scrollbarDimensions = scrollbarDimensions || measureScrollbar(); options = $.extend({}, defaults, options); validateAndEnforceOptions(); columnDefaults.width = options.defaultColumnWidth; columnsById = {}; for (var i = 0; i < columns.length; i++) { var m = columns[i] = $.extend({}, columnDefaults, columns[i]); columnsById[m.id] = i; if (m.minWidth && m.width < m.minWidth) { m.width = m.minWidth; } if (m.maxWidth && m.width > m.maxWidth) { m.width = m.maxWidth; } } // validate loaded JavaScript modules against requested options if (options.enableColumnReorder && !$.fn.sortable) { throw new Error("SlickGrid's 'enableColumnReorder = true' option requires jquery-ui.sortable module to be loaded"); } editController = { "commitCurrentEdit": commitCurrentEdit, "cancelCurrentEdit": cancelCurrentEdit }; $container .empty() .css("overflow", "hidden") .css("outline", 0) .addClass(uid) .addClass("ui-widget"); // set up a positioning container if needed if (!/relative|absolute|fixed/.test($container.css("position"))) { $container.css("position", "relative"); } $focusSink = $("

").appendTo($container); $headerScroller = $("
").appendTo($container); $headers = $("
").appendTo($headerScroller); $headers.width(getHeadersWidth()); $headerRowScroller = $("
").appendTo($container); $headerRow = $("
").appendTo($headerRowScroller); $headerRowSpacer = $("
") .css("width", getCanvasWidth() + scrollbarDimensions.width + "px") .appendTo($headerRowScroller); $topPanelScroller = $("
").appendTo($container); $topPanel = $("
").appendTo($topPanelScroller); if (!options.showTopPanel) { $topPanelScroller.hide(); } if (!options.showHeaderRow) { $headerRowScroller.hide(); } $viewport = $("
").appendTo($container); $viewport.css("overflow-y", options.autoHeight ? "hidden" : "auto"); $canvas = $("
").appendTo($viewport); $focusSink2 = $focusSink.clone().appendTo($container); if (!options.explicitInitialization) { finishInitialization(); } } function finishInitialization() { if (!initialized) { initialized = true; viewportW = parseFloat($.css($container[0], "width", true)); // header columns and cells may have different padding/border skewing width calculations (box-sizing, hello?) // calculate the diff so we can set consistent sizes measureCellPaddingAndBorder(); // for usability reasons, all text selection in SlickGrid is disabled // with the exception of input and textarea elements (selection must // be enabled there so that editors work as expected); note that // selection in grid cells (grid body) is already unavailable in // all browsers except IE disableSelection($headers); // disable all text selection in header (including input and textarea) if (!options.enableTextSelectionOnCells) { // disable text selection in grid cells except in input and textarea elements // (this is IE-specific, because selectstart event will only fire in IE) $viewport.bind("selectstart.ui", function (event) { return $(event.target).is("input,textarea"); }); } updateColumnCaches(); createColumnHeaders(); setupColumnSort(); createCssRules(); resizeCanvas(); bindAncestorScrollEvents(); $container .bind("resize.slickgrid", resizeCanvas); $viewport .bind("scroll", handleScroll); $headerScroller .bind("contextmenu", handleHeaderContextMenu) .bind("click", handleHeaderClick) .delegate(".slick-header-column", "mouseenter", handleHeaderMouseEnter) .delegate(".slick-header-column", "mouseleave", handleHeaderMouseLeave); $headerRowScroller .bind("scroll", handleHeaderRowScroll); $focusSink.add($focusSink2) .bind("keydown", handleKeyDown); $canvas .bind("keydown", handleKeyDown) .bind("click", handleClick) .bind("dblclick", handleDblClick) .bind("contextmenu", handleContextMenu) .bind("draginit", handleDragInit) .bind("dragstart", {distance: 3}, handleDragStart) .bind("drag", handleDrag) .bind("dragend", handleDragEnd) .delegate(".slick-cell", "mouseenter", handleMouseEnter) .delegate(".slick-cell", "mouseleave", handleMouseLeave); } } function registerPlugin(plugin) { plugins.unshift(plugin); plugin.init(self); } function unregisterPlugin(plugin) { for (var i = plugins.length; i >= 0; i--) { if (plugins[i] === plugin) { if (plugins[i].destroy) { plugins[i].destroy(); } plugins.splice(i, 1); break; } } } function setSelectionModel(model) { if (selectionModel) { selectionModel.onSelectedRangesChanged.unsubscribe(handleSelectedRangesChanged); if (selectionModel.destroy) { selectionModel.destroy(); } } selectionModel = model; if (selectionModel) { selectionModel.init(self); selectionModel.onSelectedRangesChanged.subscribe(handleSelectedRangesChanged); } } function getSelectionModel() { return selectionModel; } function getCanvasNode() { return $canvas[0]; } function measureScrollbar() { var $c = $("
").appendTo("body"); var dim = { width: $c.width() - $c[0].clientWidth, height: $c.height() - $c[0].clientHeight }; $c.remove(); return dim; } function getHeadersWidth() { var headersWidth = 0; for (var i = 0, ii = columns.length; i < ii; i++) { var width = columns[i].width; headersWidth += width; } headersWidth += scrollbarDimensions.width; return Math.max(headersWidth, viewportW) + 1000; } function getCanvasWidth() { var availableWidth = viewportHasVScroll ? viewportW - scrollbarDimensions.width : viewportW; var rowWidth = 0; var i = columns.length; while (i--) { rowWidth += columns[i].width; } return options.fullWidthRows ? Math.max(rowWidth, availableWidth) : rowWidth; } function updateCanvasWidth(forceColumnWidthsUpdate) { var oldCanvasWidth = canvasWidth; canvasWidth = getCanvasWidth(); if (canvasWidth != oldCanvasWidth) { $canvas.width(canvasWidth); $headerRow.width(canvasWidth); $headers.width(getHeadersWidth()); viewportHasHScroll = (canvasWidth > viewportW - scrollbarDimensions.width); } $headerRowSpacer.width(canvasWidth + (viewportHasVScroll ? scrollbarDimensions.width : 0)); if (canvasWidth != oldCanvasWidth || forceColumnWidthsUpdate) { applyColumnWidths(); } } function disableSelection($target) { if ($target && $target.jquery) { $target .attr("unselectable", "on") .css("MozUserSelect", "none") .bind("selectstart.ui", function () { return false; }); // from jquery:ui.core.js 1.7.2 } } function getMaxSupportedCssHeight() { var supportedHeight = 1000000; // FF reports the height back but still renders blank after ~6M px var testUpTo = navigator.userAgent.toLowerCase().match(/firefox/) ? 6000000 : 1000000000; var div = $("
").appendTo(document.body); while (true) { var test = supportedHeight * 2; div.css("height", test); if (test > testUpTo || div.height() !== test) { break; } else { supportedHeight = test; } } div.remove(); return supportedHeight; } // TODO: this is static. need to handle page mutation. function bindAncestorScrollEvents() { var elem = $canvas[0]; while ((elem = elem.parentNode) != document.body && elem != null) { // bind to scroll containers only if (elem == $viewport[0] || elem.scrollWidth != elem.clientWidth || elem.scrollHeight != elem.clientHeight) { var $elem = $(elem); if (!$boundAncestors) { $boundAncestors = $elem; } else { $boundAncestors = $boundAncestors.add($elem); } $elem.bind("scroll." + uid, handleActiveCellPositionChange); } } } function unbindAncestorScrollEvents() { if (!$boundAncestors) { return; } $boundAncestors.unbind("scroll." + uid); $boundAncestors = null; } function updateColumnHeader(columnId, title, toolTip) { if (!initialized) { return; } var idx = getColumnIndex(columnId); if (idx == null) { return; } var columnDef = columns[idx]; var $header = $headers.children().eq(idx); if ($header) { if (title !== undefined) { columns[idx].name = title; } if (toolTip !== undefined) { columns[idx].toolTip = toolTip; } trigger(self.onBeforeHeaderCellDestroy, { "node": $header[0], "column": columnDef }); $header .attr("title", toolTip || "") .children().eq(0).html(title); trigger(self.onHeaderCellRendered, { "node": $header[0], "column": columnDef }); } } function getHeaderRow() { return $headerRow[0]; } function getHeaderRowColumn(columnId) { var idx = getColumnIndex(columnId); var $header = $headerRow.children().eq(idx); return $header && $header[0]; } function createColumnHeaders() { function onMouseEnter() { $(this).addClass("ui-state-hover"); } function onMouseLeave() { $(this).removeClass("ui-state-hover"); } $headers.find(".slick-header-column") .each(function() { var columnDef = $(this).data("column"); if (columnDef) { trigger(self.onBeforeHeaderCellDestroy, { "node": this, "column": columnDef }); } }); $headers.empty(); $headers.width(getHeadersWidth()); $headerRow.find(".slick-headerrow-column") .each(function() { var columnDef = $(this).data("column"); if (columnDef) { trigger(self.onBeforeHeaderRowCellDestroy, { "node": this, "column": columnDef }); } }); $headerRow.empty(); for (var i = 0; i < columns.length; i++) { var m = columns[i]; var header = $("
") .html("" + m.name + "") .width(m.width - headerColumnWidthDiff) .attr("id", "" + uid + m.id) .attr("title", m.toolTip || "") .data("column", m) .addClass(m.headerCssClass || "") .appendTo($headers); if (options.enableColumnReorder || m.sortable) { header .on('mouseenter', onMouseEnter) .on('mouseleave', onMouseLeave); } if (m.sortable) { header.addClass("slick-header-sortable"); header.append(""); } trigger(self.onHeaderCellRendered, { "node": header[0], "column": m }); if (options.showHeaderRow) { var headerRowCell = $("
") .data("column", m) .appendTo($headerRow); trigger(self.onHeaderRowCellRendered, { "node": headerRowCell[0], "column": m }); } } setSortColumns(sortColumns); setupColumnResize(); if (options.enableColumnReorder) { setupColumnReorder(); } } function setupColumnSort() { $headers.click(function (e) { // temporary workaround for a bug in jQuery 1.7.1 (http://bugs.jquery.com/ticket/11328) e.metaKey = e.metaKey || e.ctrlKey; if ($(e.target).hasClass("slick-resizable-handle")) { return; } var $col = $(e.target).closest(".slick-header-column"); if (!$col.length) { return; } var column = $col.data("column"); if (column.sortable) { if (!getEditorLock().commitCurrentEdit()) { return; } var sortOpts = null; var i = 0; for (; i < sortColumns.length; i++) { if (sortColumns[i].columnId == column.id) { sortOpts = sortColumns[i]; sortOpts.sortAsc = !sortOpts.sortAsc; break; } } if (e.metaKey && options.multiColumnSort) { if (sortOpts) { sortColumns.splice(i, 1); } } else { if ((!e.shiftKey && !e.metaKey) || !options.multiColumnSort) { sortColumns = []; } if (!sortOpts) { sortOpts = { columnId: column.id, sortAsc: column.defaultSortAsc }; sortColumns.push(sortOpts); } else if (sortColumns.length == 0) { sortColumns.push(sortOpts); } } setSortColumns(sortColumns); if (!options.multiColumnSort) { trigger(self.onSort, { multiColumnSort: false, sortCol: column, sortAsc: sortOpts.sortAsc}, e); } else { trigger(self.onSort, { multiColumnSort: true, sortCols: $.map(sortColumns, function(col) { return {sortCol: columns[getColumnIndex(col.columnId)], sortAsc: col.sortAsc }; })}, e); } } }); } function setupColumnReorder() { $headers.filter(":ui-sortable").sortable("destroy"); $headers.sortable({ containment: "parent", distance: 3, axis: "x", cursor: "default", tolerance: "intersection", helper: "clone", placeholder: "slick-sortable-placeholder ui-state-default slick-header-column", forcePlaceholderSize: true, start: function (e, ui) { $(ui.helper).addClass("slick-header-column-active"); }, beforeStop: function (e, ui) { $(ui.helper).removeClass("slick-header-column-active"); }, stop: function (e) { if (!getEditorLock().commitCurrentEdit()) { $(this).sortable("cancel"); return; } var reorderedIds = $headers.sortable("toArray"); var reorderedColumns = []; for (var i = 0; i < reorderedIds.length; i++) { reorderedColumns.push(columns[getColumnIndex(reorderedIds[i].replace(uid, ""))]); } setColumns(reorderedColumns); trigger(self.onColumnsReordered, {}); e.stopPropagation(); setupColumnResize(); } }); } function setupColumnResize() { var $col, j, c, pageX, columnElements, minPageX, maxPageX, firstResizable, lastResizable; columnElements = $headers.children(); columnElements.find(".slick-resizable-handle").remove(); columnElements.each(function (i, e) { if (columns[i].resizable) { if (firstResizable === undefined) { firstResizable = i; } lastResizable = i; } }); if (firstResizable === undefined) { return; } columnElements.each(function (i, e) { if (i < firstResizable || (options.forceFitColumns && i >= lastResizable)) { return; } $col = $(e); $("
") .appendTo(e) .bind("dragstart", function (e, dd) { if (!getEditorLock().commitCurrentEdit()) { return false; } pageX = e.pageX; $(this).parent().addClass("slick-header-column-active"); var shrinkLeewayOnRight = null, stretchLeewayOnRight = null; // lock each column's width option to current width columnElements.each(function (i, e) { columns[i].previousWidth = $(e).outerWidth(); }); if (options.forceFitColumns) { shrinkLeewayOnRight = 0; stretchLeewayOnRight = 0; // colums on right affect maxPageX/minPageX for (j = i + 1; j < columnElements.length; j++) { c = columns[j]; if (c.resizable) { if (stretchLeewayOnRight !== null) { if (c.maxWidth) { stretchLeewayOnRight += c.maxWidth - c.previousWidth; } else { stretchLeewayOnRight = null; } } shrinkLeewayOnRight += c.previousWidth - Math.max(c.minWidth || 0, absoluteColumnMinWidth); } } } var shrinkLeewayOnLeft = 0, stretchLeewayOnLeft = 0; for (j = 0; j <= i; j++) { // columns on left only affect minPageX c = columns[j]; if (c.resizable) { if (stretchLeewayOnLeft !== null) { if (c.maxWidth) { stretchLeewayOnLeft += c.maxWidth - c.previousWidth; } else { stretchLeewayOnLeft = null; } } shrinkLeewayOnLeft += c.previousWidth - Math.max(c.minWidth || 0, absoluteColumnMinWidth); } } if (shrinkLeewayOnRight === null) { shrinkLeewayOnRight = 100000; } if (shrinkLeewayOnLeft === null) { shrinkLeewayOnLeft = 100000; } if (stretchLeewayOnRight === null) { stretchLeewayOnRight = 100000; } if (stretchLeewayOnLeft === null) { stretchLeewayOnLeft = 100000; } maxPageX = pageX + Math.min(shrinkLeewayOnRight, stretchLeewayOnLeft); minPageX = pageX - Math.min(shrinkLeewayOnLeft, stretchLeewayOnRight); }) .bind("drag", function (e, dd) { var actualMinWidth, d = Math.min(maxPageX, Math.max(minPageX, e.pageX)) - pageX, x; if (d < 0) { // shrink column x = d; for (j = i; j >= 0; j--) { c = columns[j]; if (c.resizable) { actualMinWidth = Math.max(c.minWidth || 0, absoluteColumnMinWidth); if (x && c.previousWidth + x < actualMinWidth) { x += c.previousWidth - actualMinWidth; c.width = actualMinWidth; } else { c.width = c.previousWidth + x; x = 0; } } } if (options.forceFitColumns) { x = -d; for (j = i + 1; j < columnElements.length; j++) { c = columns[j]; if (c.resizable) { if (x && c.maxWidth && (c.maxWidth - c.previousWidth < x)) { x -= c.maxWidth - c.previousWidth; c.width = c.maxWidth; } else { c.width = c.previousWidth + x; x = 0; } } } } } else { // stretch column x = d; for (j = i; j >= 0; j--) { c = columns[j]; if (c.resizable) { if (x && c.maxWidth && (c.maxWidth - c.previousWidth < x)) { x -= c.maxWidth - c.previousWidth; c.width = c.maxWidth; } else { c.width = c.previousWidth + x; x = 0; } } } if (options.forceFitColumns) { x = -d; for (j = i + 1; j < columnElements.length; j++) { c = columns[j]; if (c.resizable) { actualMinWidth = Math.max(c.minWidth || 0, absoluteColumnMinWidth); if (x && c.previousWidth + x < actualMinWidth) { x += c.previousWidth - actualMinWidth; c.width = actualMinWidth; } else { c.width = c.previousWidth + x; x = 0; } } } } } applyColumnHeaderWidths(); if (options.syncColumnCellResize) { applyColumnWidths(); } }) .bind("dragend", function (e, dd) { var newWidth; $(this).parent().removeClass("slick-header-column-active"); for (j = 0; j < columnElements.length; j++) { c = columns[j]; newWidth = $(columnElements[j]).outerWidth(); if (c.previousWidth !== newWidth && c.rerenderOnResize) { invalidateAllRows(); } } updateCanvasWidth(true); render(); trigger(self.onColumnsResized, {}); }); }); } function getVBoxDelta($el) { var p = ["borderTopWidth", "borderBottomWidth", "paddingTop", "paddingBottom"]; var delta = 0; $.each(p, function (n, val) { delta += parseFloat($el.css(val)) || 0; }); return delta; } function measureCellPaddingAndBorder() { var el; var h = ["borderLeftWidth", "borderRightWidth", "paddingLeft", "paddingRight"]; var v = ["borderTopWidth", "borderBottomWidth", "paddingTop", "paddingBottom"]; el = $("").appendTo($headers); headerColumnWidthDiff = headerColumnHeightDiff = 0; $.each(h, function (n, val) { headerColumnWidthDiff += parseFloat(el.css(val)) || 0; }); $.each(v, function (n, val) { headerColumnHeightDiff += parseFloat(el.css(val)) || 0; }); el.remove(); var r = $("
").appendTo($canvas); el = $("").appendTo(r); cellWidthDiff = cellHeightDiff = 0; $.each(h, function (n, val) { cellWidthDiff += parseFloat(el.css(val)) || 0; }); $.each(v, function (n, val) { cellHeightDiff += parseFloat(el.css(val)) || 0; }); r.remove(); absoluteColumnMinWidth = Math.max(headerColumnWidthDiff, cellWidthDiff); } function createCssRules() { $style = $("