-
-
Save markschl/c8317978e1d26bf049bb to your computer and use it in GitHub Desktop.
| /** | |
| * 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); |
Hey, this looks just like what I might need! Which is the last version? The one from the comment or the one in the gist? Thank you!
Sorry for not responding, I was quite busy with other things. I updated the Gist, should now work again (see also link http://jsfiddle.net/7Z3bD/45/). I also removed usages of jQuery and did some minor reformatting. Of course, there are still the limitations described in the issue; viewing works nicely, but editing will cause problems...
Updated to work with manualColumnFreeze and manualColumnMove. Additionally, it is now possible to keep a reference to the data array by using the data option. Before, it was only accessible through Handsontable.LazyLoader.data.
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!
Updated the plugin to work for latest environment (0.16)
/**
*
(function (Handsontable) {
"use strict";
})(Handsontable);