Last active
April 23, 2017 13:02
-
-
Save markschl/c8317978e1d26bf049bb to your computer and use it in GitHub Desktop.
Attempt to implement a Handsontable (www.handsontable.com) plugin allowing deferred loading of data (see also https://github.com/warpech/jquery-handsontable/issues/607). For an example see: http://jsfiddle.net/7Z3bD/54/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* This Handsontable plugin implements deferred data loading, useful for | |
* large tables ("infinite scrolling"). | |
* | |
* | |
* Example usage | |
* ------------- | |
* In this example data is fetched from a Django + Tastypie Api | |
* | |
* var hot = new Handsontable(document.getElementById('table'), { | |
* dataSource: function(page, cb) { | |
* $.get("/api/v1/tablename", {page: page}).sucess(function(result) { | |
* cb(result.objects, result.meta.total_count); | |
* }); | |
* }, | |
* pageSize: 400, | |
* rowsBuffered: 400 | |
* }); | |
* | |
* | |
* Options explained | |
* ----------------- | |
* | |
* - dataSource (mandatory): | |
* Data loading function. It replaces the *data* | |
* option / the loadData() function. | |
* It's arguments are: | |
* 1. a page number (1-based) | |
* 2. A callback function. This function accepts two arguments: | |
* 1. the data array | |
* 2. the total row count. After the first page is loaded, | |
* the data array of the given size is created. | |
* Changes to the total count are currently not taken into | |
* account | |
* | |
* - data (optional): | |
* If specified, the given array will be filled with data as the table | |
* is scrolled. However, all data it contained before will be removed. | |
* | |
* - pageSize (optional): | |
* Number of rows to load in one batch. | |
* Default: 100 | |
* | |
* - rowsBuffered (optional): | |
* Number of rows above and below the visible range to be automatically | |
* preloaded | |
* Default: 100 | |
* | |
* - loadDelay (optional): | |
* Delay in ms before starting a page load. This is | |
* done to prevent every page from being loaded | |
* during fast scrolling. | |
* Maybe it could be made more intelligent by determining | |
* the scrolling speed? | |
* Default: 100 | |
* | |
* - loadingMsg (optional): | |
* DOM selector that should act as replacement for the default | |
*/ | |
(function(Handsontable) { | |
"use strict"; | |
/** | |
* @class | |
*/ | |
function LazyLoader(hot, opt) { | |
this.hot = hot; | |
this.dataSource = opt.dataSource; | |
this.pagesize = opt.pageSize || 100; | |
this.rows_buffered = typeof opt.rowsBuffered === "number" ? opt.rowsBuffered : 0; | |
this.load_delay = opt.loadDelay || 100; | |
this.data = opt.data || new Array(1000); | |
if (opt.loadingMsg) { | |
this.loading_msg = opt.loadingMsg; | |
} else { | |
this.loading_msg = document.createElement("div"); | |
this.loading_msg.className = "hot-loading-msg" | |
this.loading_msg.appendChild(document.createTextNode("Loading...")); | |
var s = this.loading_msg.style; | |
s.color = "#444"; | |
s.borderRadius = "0.5em"; | |
s.backgroundColor = "rgba(250,250,250,0.9)"; | |
s.border = "1px solid rgba(150,150,150,0.9)"; | |
s.padding = "1em"; | |
} | |
document.body.appendChild(this.loading_msg); | |
var s = this.loading_msg.style; | |
s.position = "absolute"; | |
s.zIndex = 999999; | |
this.msg_offset_top = Math.round(this.loading_msg.clientHeight / 2); | |
this.msg_offset_left = Math.round(this.loading_msg.clientWidth / 2); | |
s.display = "none"; | |
this.loading = {}; | |
this.init(); | |
} | |
/** | |
* Clears and initializes the table. This function is called | |
* each time the data has to be reloaded. | |
* @param {LazyLoader~loadCallback} cb - optional callback function passed to loadPage() | |
*/ | |
LazyLoader.prototype.init = function(cb) { | |
this.pages_loaded = {}; | |
this.offsets = []; | |
this.resetOffsetCache(); | |
this.is_new = true; | |
// clear the data array if it already exists | |
if (this.data !== null) { | |
this.data.splice.apply(this.data, [0, this.data.length].concat(new Array(this.data.length))); | |
} | |
var _this = this; | |
this.loadPage(1, function() { | |
_this.hot.loadData(_this.data); | |
cb && cb.apply(this, arguments); | |
}); | |
}; | |
/** | |
* Determines the currently visible row range and page (range). | |
* If necessary, fetches missing data using the dataSource function. | |
* @param {boolean} settingsChanged - true if the render was forced by a | |
* settings change (not scrolling), see isForced parameter from afterRender event | |
*/ | |
LazyLoader.prototype.update = function(settingsChanged) { | |
if (settingsChanged) { | |
/* update the position of the loading message */ | |
var cnt = this.hot.container, | |
off = Handsontable.Dom.offset(cnt); | |
this.loading_msg.style.top = off.top + Math.round(cnt.clientHeight / 2) - this.msg_offset_top + "px"; | |
this.loading_msg.style.left = off.left + Math.round(cnt.clientWidth / 2) - this.msg_offset_left + "px"; | |
} | |
/* get row ranges */ | |
var first_row_visible = this.hot.rowOffset(), | |
last_row_visible = first_row_visible + this.hot.countVisibleRows(); | |
first_row_visible -= this.getOffset(first_row_visible); | |
last_row_visible -= this.getOffset(last_row_visible); | |
/* These variables are always up to date */ | |
this.first_page_visible = this.getPage(first_row_visible); | |
this.last_page_visible = this.getPage(last_row_visible); | |
var first_now, last_now; | |
this.buf_first_page = first_now = this.getPage(Math.max(1, first_row_visible - this.rows_buffered)); | |
this.buf_last_page = last_now = this.getPage(Math.min(this.data.length, last_row_visible + this.rows_buffered)); | |
var not_loaded = [], | |
page; | |
for (page = first_now; page <= last_now; page++) { | |
if (!(this.isLoading(page) || this.isLoaded(page))) { | |
not_loaded.push(page); | |
} | |
} | |
if (not_loaded.length) { | |
/* wait for a short time before loading the page, then check again | |
if still to be loaded */ | |
// TODO: Any better way to deal with scrolling? | |
var _this = this; | |
setTimeout(function() { | |
for (var i = 0, len = not_loaded.length; i < len; i++) { | |
page = not_loaded[i]; | |
if (!(_this.isLoading(page) || _this.isLoaded(page)) && | |
page >= _this.buf_first_page && | |
page <= _this.buf_last_page) { | |
_this.loadPage(page, function(pg) { | |
if (_this.isVisible(pg)) { | |
// TODO: this triggers afterRender() again! | |
_this.hot.render(); | |
} | |
_this.updateLoadingMessage(); | |
}); | |
} | |
} | |
}, this.load_delay); | |
} | |
}; | |
/** | |
* @param {number} rowindex - row index (0-based) | |
* @returns a page number (1-based) | |
*/ | |
LazyLoader.prototype.getPage = function(rowindex) { | |
return Math.floor(rowindex / this.pagesize + 1); | |
}; | |
/** | |
* Determines whether any of the visible rows | |
* are currently loading and displays a message | |
* if necessary. | |
*/ | |
LazyLoader.prototype.updateLoadingMessage = function() { | |
for (var page = this.first_page_visible, lv = this.last_page_visible; page <= lv; page++) { | |
if (this.isLoading(page)) { | |
this.loading_msg.style.display = "block"; | |
return; | |
} | |
} | |
this.loading_msg.style.display = "none"; | |
}; | |
/** | |
* Loads rows from one page into the data array. Afterwards, a custom | |
* plugin hook called 'afterLoadPage' is executed. | |
* @param {number} page - Page number to load (1-based index) | |
* @param {LazyLoader~loadCallback} cb - Function called when done | |
* @returns {boolean} true if the page is already loaded, otherwise false | |
*/ | |
LazyLoader.prototype.loadPage = function(page, cb) { | |
if (this.isLoaded(page)) { | |
return true; | |
} | |
if (!this.isLoading(page)) { | |
this.loading[page] = true; | |
this.updateLoadingMessage(); | |
var _this = this; | |
this.dataSource(page, function(data, size) { | |
if (_this.is_new) { | |
_this.data.length = size; | |
} | |
_this.pages_loaded[page] = true; | |
delete _this.loading[page]; | |
if ((page - 1) * _this.pagesize >= size) { | |
// TODO: why does this even happen? | |
_this.updateLoadingMessage(); | |
//throw "Page " + page + " is out of range."; | |
return; | |
} | |
var first = _this.getCorrectedPosition((page - 1) * _this.pagesize), | |
last = first + _this.pagesize - 1; | |
_this.data.splice.apply( | |
_this.data, [first, _this.pagesize].concat(data.slice(0, _this.pagesize)) | |
); | |
cb && cb(page, first, last, _this.is_new); | |
_this.hot.runHooks('afterLoadPage', page, first, last, _this.is_new); | |
if (_this.is_new) { | |
_this.is_new = false; | |
} | |
}); | |
} | |
return false; | |
}; | |
/** | |
* @param {number} page - Page number | |
*/ | |
LazyLoader.prototype.isLoading = function(page) { | |
return page in this.loading; | |
}; | |
/** | |
* @param {number} page - Page number | |
*/ | |
LazyLoader.prototype.isLoaded = function(page) { | |
return page in this.pages_loaded; | |
}; | |
/** | |
* @param {number} page - Page number | |
*/ | |
LazyLoader.prototype.isVisible = function(page) { | |
return page >= this.first_page_visible && page <= this.last_page_visible; | |
}; | |
/** | |
* Sets an offset at a given row index. This information is used | |
* by getOffset() | |
* @param {number} index - row index | |
* @param {number} offset - row offset (positive = insertion, negative = deletion) | |
*/ | |
LazyLoader.prototype.setOffset = function(index, offset) { | |
/* this.offsets is always kept sorted by index */ | |
var data; | |
for (var i = this.offsets.length - 1; i >= 0; i--) { | |
data = this.offsets[i]; | |
if (index <= data[0] && data[0] - index >= data[1] && | |
offset > 0 && data[1] > 0) { | |
/* this offset can be added to an existing offset */ | |
data[0] += offset; | |
data[1] += offset; | |
this.resetOffsetCache(); | |
return; | |
} | |
if (index >= data[0]) { | |
break; | |
} | |
/* offsets at higher positions need to be adjusted */ | |
if (offset < 0 && data[0] < index - offset) { | |
/* prevent moving indices below first deleted row */ | |
data[0] = index; | |
} else { | |
data[0] += offset; | |
} | |
} | |
if (offset > 0) { | |
index += offset; | |
} | |
this.offsets.splice(i + 1, 0, [index, offset]); | |
this.resetOffsetCache(); | |
}; | |
/** | |
* Determines the offset introduced by insertions/deletions | |
* (see setOffset()) at the given position in the table. | |
* @param {number} position - row index | |
* @returns {number} Positive or negative offset | |
*/ | |
LazyLoader.prototype.getOffset = function(position) { | |
if (this.current_offset !== null && position >= this.current_offset[0] && | |
(this.next_offset_index === null || position <= this.next_offset_index[0])) { | |
// row with same offset as last time -> return cached offset | |
return this.current_offset[1]; | |
} | |
var cum_offset = 0, | |
data = null, | |
prev_data = null, | |
offsets = this.offsets; | |
this.next_offset_index = null; | |
for (var i = 0, len = offsets.length; i < len; i++) { | |
data = offsets[i]; | |
if (data[0] >= position) { | |
this.next_offset_index = data[0]; | |
break; | |
} | |
cum_offset += data[1]; | |
prev_data = data; | |
} | |
this.current_offset = [(prev_data === null ? 0 : prev_data[0]), cum_offset]; | |
return cum_offset; | |
}; | |
/** | |
* Adjusts a given row index by the offset at this position | |
* @param {number} position - row index | |
* @returns {number} adjusted row index | |
*/ | |
LazyLoader.prototype.getCorrectedPosition = function(position) { | |
position += this.getOffset(position); | |
return position; | |
}; | |
/** | |
* Clears the cache used to accelerate getOffset() calls | |
*/ | |
LazyLoader.prototype.resetOffsetCache = function() { | |
this.current_offset = null; | |
this.next_offset_index = null; | |
}; | |
/** | |
* @callback LazyLoader~loadCallback | |
* @param {number} page - page number | |
* @param {number} first - index of first row (corrected by offsets) | |
* @param {number} last - index of last row (corrected by offsets) | |
* @param {boolean} is_new - true if loading data for the first time | |
* after LazyLoader.init() | |
*/ | |
Handsontable.hooks.add("afterInit", function() { | |
var opt = this.getSettings(); | |
if ("dataSource" in opt) { | |
this.LazyLoader = new LazyLoader(this, opt); | |
/* The 'afterRender' callback triggers data loading */ | |
this.addHook("afterRender", function(isForced) { | |
this.LazyLoader.update(isForced); | |
}); | |
/* The plugin needs to keep track of row removals/additions for always being | |
able to correctly determine which items are visible at a given time */ | |
// TODO: not yet investigated whether the UndoRedo infrastructure could be used for this | |
this.addHook("beforeRemoveRow", function(index, amount) { | |
this.LazyLoader.setOffset(index, -1 * amount); | |
}); | |
this.addHook("afterCreateRow", function(index, amount) { | |
this.LazyLoader.setOffset(index, amount); | |
}); | |
} | |
}); | |
})(Handsontable); |
When I use code from this gist, browser is freezing. If I use code from http://jsfiddle.net/7Z3bD/54/ , everything is okay.
Anyway, thank you for your plugin!
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Updated to work with
manualColumnFreeze
andmanualColumnMove
. Additionally, it is now possible to keep a reference to the data array by using thedata
option. Before, it was only accessible throughHandsontable.LazyLoader.data
.