slickgrid
Version:
A lightning fast JavaScript grid/spreadsheet
762 lines (648 loc) • 20.8 kB
JavaScript
/***
* Contains core SlickGrid classes.
* @module Core
* @namespace Slick
*/
(function ($) {
/***
* An event object for passing data to event handlers and letting them control propagation.
* <p>This is pretty much identical to how W3C and jQuery implement events.</p>
* @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.
* <p>Event handler will receive two arguments - an <code>EventData</code> and the <code>data</code>
* object the event was fired with.<p>
* @method subscribe
* @param fn {Function} Event handler.
*/
this.subscribe = function (fn) {
handlers.push(fn);
};
/***
* Removes an event handler added with <code>subscribe(fn)</code>.
* @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 <code>EventData</code> 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 <code>Event</code> 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 <code>fromRow</code>.
* @param toCell {Integer} Optional. Ending cell. Defaults to <code>fromCell</code>.
*/
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;
/***
* Whether a group selection checkbox is checked.
* @property selectChecked
* @type {Boolean}
*/
this.selectChecked = 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 &&
this.title === group.title;
};
/***
* 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;
/***
* Whether the totals have been fully initialized / calculated.
* Will be set to false for lazy-calculated group totals.
* @param initialized
* @type {Boolean}
*/
this.initialized = false;
}
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 throw new Error(.
* @method activate
* @param editController {EditController} edit controller acquiring the lock
*/
this.activate = function (editController) {
if (editController === activeEditController) { // already activated?
return;
}
if (activeEditController !== null) {
throw new Error("SlickGrid.EditorLock.activate: an editController is still active, can't activate another editController");
}
if (!editController.commitCurrentEdit) {
throw new Error("SlickGrid.EditorLock.activate: editController must implement .commitCurrentEdit()");
}
if (!editController.cancelCurrentEdit) {
throw new Error("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 throw new Error(.
* @method deactivate
* @param editController {EditController} edit controller releasing the lock
*/
this.deactivate = function (editController) {
if (!activeEditController) {
return;
}
if (activeEditController !== editController) {
throw new Error("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);
};
}
/**
*
* @param {Array} treeColumns Array com levels of columns
* @returns {{hasDepth: 'hasDepth', getTreeColumns: 'getTreeColumns', extractColumns: 'extractColumns', getDepth: 'getDepth', getColumnsInDepth: 'getColumnsInDepth', getColumnsInGroup: 'getColumnsInGroup', visibleColumns: 'visibleColumns', filter: 'filter', reOrder: reOrder}}
* @constructor
*/
function TreeColumns(treeColumns) {
var columnsById = {};
function init() {
mapToId(treeColumns);
}
function mapToId(columns) {
columns
.forEach(function (column) {
columnsById[column.id] = column;
if (column.columns)
mapToId(column.columns);
});
}
function filter(node, condition) {
return node.filter(function (column) {
var valid = condition.call(column);
if (valid && column.columns)
column.columns = filter(column.columns, condition);
return valid && (!column.columns || column.columns.length);
});
}
function sort(columns, grid) {
columns
.sort(function (a, b) {
var indexA = getOrDefault(grid.getColumnIndex(a.id)),
indexB = getOrDefault(grid.getColumnIndex(b.id));
return indexA - indexB;
})
.forEach(function (column) {
if (column.columns)
sort(column.columns, grid);
});
}
function getOrDefault(value) {
return typeof value === 'undefined' ? -1 : value;
}
function getDepth(node) {
if (node.length)
for (var i in node)
return getDepth(node[i]);
else if (node.columns)
return 1 + getDepth(node.columns);
else
return 1;
}
function getColumnsInDepth(node, depth, current) {
var columns = [];
current = current || 0;
if (depth == current) {
if (node.length)
node.forEach(function(n) {
if (n.columns)
n.extractColumns = function() {
return extractColumns(n);
};
});
return node;
} else
for (var i in node)
if (node[i].columns) {
columns = columns.concat(getColumnsInDepth(node[i].columns, depth, current + 1));
}
return columns;
}
function extractColumns(node) {
var result = [];
if (node.hasOwnProperty('length')) {
for (var i = 0; i < node.length; i++)
result = result.concat(extractColumns(node[i]));
} else {
if (node.hasOwnProperty('columns'))
result = result.concat(extractColumns(node.columns));
else
return node;
}
return result;
}
function cloneTreeColumns() {
return $.extend(true, [], treeColumns);
}
init();
this.hasDepth = function () {
for (var i in treeColumns)
if (treeColumns[i].hasOwnProperty('columns'))
return true;
return false;
};
this.getTreeColumns = function () {
return treeColumns;
};
this.extractColumns = function () {
return this.hasDepth()? extractColumns(treeColumns): treeColumns;
};
this.getDepth = function () {
return getDepth(treeColumns);
};
this.getColumnsInDepth = function (depth) {
return getColumnsInDepth(treeColumns, depth);
};
this.getColumnsInGroup = function (groups) {
return extractColumns(groups);
};
this.visibleColumns = function () {
return filter(cloneTreeColumns(), function () {
return this.visible;
});
};
this.filter = function (condition) {
return filter(cloneTreeColumns(), condition);
};
this.reOrder = function (grid) {
return sort(treeColumns, grid);
};
this.getById = function (id) {
return columnsById[id];
};
this.getInIds = function (ids) {
return ids.map(function (id) {
return columnsById[id];
});
};
}
/***
* Polyfill for Map to support old browsers but
* benefit of the Map speed in modern browsers.
* @class Map
* @constructor
*/
var Map = 'Map' in window ? window.Map : function Map() {
var data = {};
/***
* Gets the item with the given key from the map or undefined if
* the map does not contain the item.
* @method get
* @param key {Map} The key of the item in the map.
*/
this.get = function(key) {
return data[key];
};
/***
* Adds or updates the item with the given key in the map.
* @method set
* @param key The key of the item in the map.
* @param value The value to insert into the map of the item in the map.
*/
this.set = function(key, value) {
data[key] = value;
};
/***
* Gets a value indicating whether the given key is present in the map.
* @method has
* @param key The key of the item in the map.
* @return {Boolean}
*/
this.has = function(key) {
return key in data;
};
/***
* Removes the item with the given key from the map.
* @method delete
* @param key The key of the item in the map.
*/
this.delete = function(key) {
delete data[key];
};
};
function regexSanitizer(dirtyHtml) {
return dirtyHtml.replace(/(\b)(on[a-z]+)(\s*)=|javascript:([^>]*)[^>]*|(<\s*)(\/*)script([<>]*).*(<\s*)(\/*)script(>*)|(<)(\/*)(script|script defer)(.*)(>|>">)/gi, '');
}
// exports
$.extend(true, window, {
"Slick": {
"Event": Event,
"EventData": EventData,
"EventHandler": EventHandler,
"Range": Range,
"Map": Map,
"NonDataRow": NonDataItem,
"Group": Group,
"GroupTotals": GroupTotals,
"RegexSanitizer": regexSanitizer,
"EditorLock": EditorLock,
/***
* A global singleton editor lock.
* @class GlobalEditorLock
* @static
* @constructor
*/
"GlobalEditorLock": new EditorLock(),
"TreeColumns": TreeColumns,
"keyCode": {
SPACE: 8,
BACKSPACE: 8,
DELETE: 46,
DOWN: 40,
END: 35,
ENTER: 13,
ESCAPE: 27,
HOME: 36,
INSERT: 45,
LEFT: 37,
PAGE_DOWN: 34,
PAGE_UP: 33,
RIGHT: 39,
TAB: 9,
UP: 38,
A: 65
},
"preClickClassName" : "slick-edit-preclick",
"GridAutosizeColsMode": {
None: 'NOA',
LegacyOff: 'LOF',
LegacyForceFit: 'LFF',
IgnoreViewport: 'IGV',
FitColsToViewport: 'FCV',
FitViewportToCols: 'FVC'
},
"ColAutosizeMode": {
Locked: 'LCK',
Guide: 'GUI',
Content: 'CON',
ContentIntelligent: 'CTI'
},
"RowSelectionMode": {
FirstRow: 'FS1',
FirstNRows: 'FSN',
AllRows: 'ALL',
LastRow: 'LS1'
},
"ValueFilterMode": {
None: 'NONE',
DeDuplicate: 'DEDP',
GetGreatestAndSub: 'GR8T',
GetLongestTextAndSub: 'LNSB',
GetLongestText: 'LNSC'
},
"WidthEvalMode": {
CanvasTextSize: 'CANV',
HTML: 'HTML'
}
}
});
})(jQuery);