%PDF- %PDF-
Direktori : /proc/985914/root/data/old/home/stash/atlassian-stash/static/widget/ |
Current File : //proc/985914/root/data/old/home/stash/atlassian-stash/static/widget/paged-scrollable.js |
define('widget/paged-scrollable', [ 'aui', 'jquery', 'underscore', 'util/deprecation', 'util/events', 'util/function', 'widget/loaded-range' ], function( AJS, $, _, deprecate, events, fn, LoadedRange ) { 'use strict'; var isIE = $.browser.msie; /** * An abstract widget that will handle scroll events and load new data as the user nears the edge of the page. * * To extend PagedScrollable, you must implement: * this.requestData(start, limit) : must return a promise with a RestPage object as the first done() argument. * this.attachNewContent(data, attachmentMethod) : given a RestPage object and an attachmentMethod ('prepend', 'append', or 'html'), * should add new content to the element specified by scrollContentSelector * * @param scrollPaneSelector - the element with overflow: auto | scroll * @param options see PagedScrollable.defaults. */ function PagedScrollable(scrollPaneSelector, options) { this.options = $.extend({}, PagedScrollable.defaults, options); this.$scrollElement = $(scrollPaneSelector || window); if ($.isWindow(this.$scrollElement[0])) { // we still want to attach to window.scroll, but documentElement has the properties we need to look at. var docEl = window.document.documentElement; this.getPaneHeight = function() { return docEl.clientHeight; }; this.getContentHeight = function() { return docEl.scrollHeight; }; } this._eventHandlers = []; } /** * pageSize: used as the limit parameter to requestData, * scrollDelay: the number of milliseconds to debounce before handling a scroll event. * bufferPixels: load more data if the user scrolls within this many pixels of the edge of the loaded data. * precedingSpaceMaintained: Set this to true if your implementation will add blank space above the loaded content as a placeholder for content * that will be loaded as the user scrolls up. Setting this to true means PagedScrollable will load a previous page when * the proportion of the content scrolled is less than the start of your loaded range (i.e., wandering into the 'placeholder' territory you've created. * <code>scrollElement.scrollTop < bufferPixels + (loadedRange.start / loadedRange.nextPageStart) * scrollContent.height</code> * Setting it false means a previous page will be loaded when * <code>scrollElement.scrollTop < bufferPixels</code> * suspendOnFailure : When enabled, the paged-scrollable will enter a suspended mode, as if PagedScrollable.suspend() was called after a data request fails. * To resume requesting data, call PagedScrollable.resume(). * dataLoadedEvent : name of the events event that will be fired when data is loaded * autoLoad : Whether to automatically load the previous/next page as you scroll to the top/bottom. Either 'previous', 'next', true (for both directions) or false. * preventOverscroll: Whether to prevent scrolling past the beginning or end of a scrollable element from causing the window to scroll */ PagedScrollable.defaults = { pageSize: 50, scrollDelay : 250, bufferPixels : 0, precedingSpaceMaintained : true, suspendOnFailure : true, dataLoadedEvent: 'stash.widget.pagedscrollable.dataLoaded', autoLoad: true, preventOverscroll: false, idForEntity: null }; //load the initial data PagedScrollable.prototype.init = function(options) { PagedScrollable.prototype.reset.call(this); options = options || {}; this.loadedRange = options.loadedRange || new LoadedRange(); var self = this; var pageSize = this.options.pageSize; var startAtItem = options.targetedItem ? Math.floor(options.targetedItem / pageSize) * pageSize : 0; if (options.suspended) { this.suspend(); } // if the start item is already loaded we probably don't have to load any more. if (this.loadedRange.isLoaded(startAtItem)) { // but it's possible the window is larger than the page size, so trigger a fake scroll anyway just to see if that causes any new loads. return (this.loadIfRequired() || $.Deferred().resolve()).done(function() { // do our onFirstDataLoaded call now if we can, or after whatever loads next. self.onFirstDataLoaded(); }); } return loadInternal(this, startAtItem, pageSize).then(undefined, function() { // TODO: base this on the error returned from REST. Only do it if the line is out of range for the file. var isPastEnd = startAtItem !== 0; if (isPastEnd) { // fallback to the first page. return loadInternal(self, 0, pageSize); } else { // fail the same way as before. return $.Deferred().rejectWith(this, arguments); } }).fail(function(xhr, text, error, data) { if (data && data.errors && data.errors.length) { self.handleErrors(data.errors); } // else assume error was already handled. }); }; PagedScrollable.prototype.reset = function() { if (this.currentXHR) { this.cancelRequest(); } this.clearListeners(); if (this._resizeHandler) { $(window).off('resize', this._resizeHandler); this._resizeHandler = null; } if (this.options.idForEntity) { this._ids = {}; } // must happen after this.cancelRequest() to avoid the scrollable becoming suspended on a reinit. this._suspended = false; }; PagedScrollable.prototype.destroy = function() { this.reset(); delete this.$scrollElement; }; /** * Stop requesting new data. Any requests already in the pipeline will complete. * To resume requesting data, call PagedScrollable.resume(); */ PagedScrollable.prototype.suspend = function () { this._suspended = true; }; /** * Resume requesting new data. */ PagedScrollable.prototype.resume = function () { this._suspended = false; // if they are near the top/bottom of the page, request the data they need immediately. return this.loadIfRequired(); }; PagedScrollable.prototype.isSuspended = function() { return this._suspended; }; /** * @returns {Number} the scroll top of the scrollable element */ PagedScrollable.prototype.getScrollTop = function() { return this.$scrollElement.scrollTop(); }; /** * Set the scroll top of the scrollable element * * @param {Number} scrollTop the scroll to to set */ PagedScrollable.prototype.setScrollTop = function(scrollTop) { this.$scrollElement.scrollTop(scrollTop); }; /** * @returns {jQuery} a jQuery object for the scrollable element */ PagedScrollable.prototype.getPane = function() { return this.$scrollElement; }; /** * @returns {Number} the visible height of the scrollable element */ PagedScrollable.prototype.getPaneHeight = function() { return this.$scrollElement[0].clientHeight; }; /** * @returns {Number} the actual height of the scrollable element */ PagedScrollable.prototype.getContentHeight = function() { return this.$scrollElement[0].scrollHeight; }; /** * @param {String} opt the option to return * @returns {*} the value of the object */ PagedScrollable.prototype.getOption = function(opt) { if (Object.prototype.hasOwnProperty.call(this.options, opt)) { return this.options[opt]; } return undefined; }; /** * Override the existing options * * @param {Object} opts the options to override */ PagedScrollable.prototype.setOptions = function(opts) { if ($.isPlainObject(opts)) { this.options = $.extend(this.options, opts); } }; /** * Bind a scroll listener to the paged scrollable * * @param {Function} func the scroll listener */ PagedScrollable.prototype.addScrollListener = function(func) { var handler = this.scrollDelay ? _.debounce(func, this.scrollDelay) : func; this._eventHandlers.push(handler); this.$scrollElement.on('scroll.paged-scrollable', handler); }; /** * @deprecated since 2.6. Clear all listeners using clearListeners(). * @type {Function} */ PagedScrollable.prototype.clearScrollListeners = deprecate.fn(PagedScrollable.prototype.clearListeners, 'widget/paged-scrollable::clearScrollListeners', 'widget/paged-scrollable::clearListeners', '2.6', '3.0'); PagedScrollable.prototype._bindOverscrollPrevention = function() { function overscrollPrevention(e, delta) { var height = $(this).outerHeight(); var scrollHeight = this.scrollHeight; if((this.scrollTop === (scrollHeight - height) && delta < 0) || (this.scrollTop === 0 && delta > 0)) { //If at the bottom scrolling down, or at the top scrolling up e.preventDefault(); } } this._eventHandlers.push(overscrollPrevention); this.$scrollElement.on('mousewheel.paged-scrollable', overscrollPrevention); }; /** * Unbind all listeners */ PagedScrollable.prototype.clearListeners = function() { var self = this; _.each(this._eventHandlers, function (handler) { self.$scrollElement.unbind('.paged-scrollable', handler); }); this._eventHandlers.length = 0; }; PagedScrollable.prototype.loadIfRequired = function() { if (this.isSuspended() || (this.loadedRange.reachedEnd() && this.loadedRange.reachedStart())) { return; } var scrollTop = this.getScrollTop(), scrollPaneHeight = this.getPaneHeight(), contentHeight = this.getContentHeight(), scrollBottom = scrollPaneHeight + scrollTop; if (!$.isWindow(this.getPane()[0]) && this.getPane().is(":hidden")) { return; } // paging previous might not work if page is filtered and deeplinked if (_.any([true, 'previous'], fn.eq(this.options.autoLoad)) && scrollTop < this.options.bufferPixels + (this.loadedRange.start / this.loadedRange.nextPageStart) * contentHeight) { var pageBefore = this.loadedRange.pageBefore(this.options.pageSize); if (pageBefore) { return this.load(pageBefore.start, pageBefore.limit); } } // In Chrome on Windows at some font sizes (Ctrl +), the scrollPaneHeight is rounded down, but contentHeight is // rounded up (I think). This means there is a 1px difference between them and the event won't fire. var chromeWindowsFontChangeBuffer = 1; if (_.any([true, 'next'], fn.eq(this.options.autoLoad)) && scrollBottom + chromeWindowsFontChangeBuffer >= contentHeight - this.options.bufferPixels) { var pageAfter = this.loadedRange.pageAfter(this.options.pageSize); if (pageAfter) { return this.load(pageAfter.start, pageAfter.limit); } } }; function loadInternal(self, start, limit) { if (self.currentXHR) { return $.Deferred().reject(); } self.currentXHR = self.requestData(start, limit); return self.currentXHR.always(function () { self.currentXHR = null; }) .done(function(data) { self.onDataLoaded(start, limit, data); }) .fail(function() { self.suspend(); }); } PagedScrollable.prototype.load = function(start, limit) { var self = this; return loadInternal(this, start, limit).fail(function(xhr, text, error, data) { if (data && data.errors) { self.handleErrors(data.errors); } }); }; PagedScrollable.prototype.loadAfter = function() { var pageAfter = this.loadedRange.pageAfter(this.options.pageSize); return pageAfter && this.load(pageAfter.start, pageAfter.limit); }; PagedScrollable.prototype.loadBefore = function() { var pageBefore = this.loadedRange.pageBefore(this.options.pageSize); return pageBefore && this.load(pageBefore.start, pageBefore.limit); }; PagedScrollable.prototype.onDataLoaded = function(start, limit, data) { if (data.start !== undefined) { start = data.start; } var firstLoad = this.loadedRange.isEmpty(), attachmentMethod = this.loadedRange.getAttachmentMethod(start, data.size), isPrepend = attachmentMethod === 'prepend'; this.loadedRange.add(start, data.size, data.isLastPage, data.nextPageStart); var oldHeight, oldScrollTop; if (isPrepend || isIE) { // values for calculating offset oldScrollTop = this.getScrollTop(); oldHeight = this.getContentHeight(); } data = this._addPage(data, attachmentMethod); // scroll to where the user was before we added new data. IE reverts to the initial position (top or line // specified in hash) when you append content, so we need to always rescroll in IE. if (isPrepend || isIE) { var heightAddedAbove = isPrepend ? this.getContentHeight() - oldHeight : 0; this.setScrollTop(oldScrollTop + heightAddedAbove); } if (firstLoad) { this.onFirstDataLoaded(start, limit, data); } events.trigger(this.options.dataLoadedEvent, this, start, limit, data ); //retrigger scroll - load more if we're still at the edges. this.loadIfRequired(); }; /** * Filters duplicates and adds the page to the table. * * @param {Object} data the original page data * @param attachmentMethod: <code>prepend</code>, <code>append</code> or <code>html</code> * @returns {Object} data the filtered page data added to the table * @private */ PagedScrollable.prototype._addPage = function(data, attachmentMethod) { data = this._dedupe(data); this.attachNewContent(data, attachmentMethod); return data; }; /** * Remove any duplicate entities which have already appeared in the PagedScrollable * * @param {Object} data the original data which will not be modified * @returns {Object} the data modified * @private */ PagedScrollable.prototype._dedupe = function(data) { // Only dedupe data when a idForEntity is provided and // the data is a page if (data && data.values && this.options.idForEntity) { var ids = this._ids; var idForEntity = this.options.idForEntity; data = $.extend({}, data, { values: _.filter(data.values, function(entity) { var id = idForEntity(entity); if (!_.has(ids, id)) { ids[id] = true; return true; } return false; }) }); } return data; }; PagedScrollable.prototype.onFirstDataLoaded = function() { var self = this; this.addScrollListener(function() { self.loadIfRequired(); }); if (this.options.preventOverscroll) { this._bindOverscrollPrevention(); } $(window).on('resize', this._resizeHandler = function() { self.loadIfRequired(); }); }; PagedScrollable.prototype.cancelRequest = function() { if (this.currentXHR) { if (this.currentXHR.abort) { this.currentXHR.abort(); } else if (this.currentXHR.reject) { this.currentXHR.reject(); } else { AJS.log("Couldn't cancel the current request."); } this.currentXHR = null; } }; PagedScrollable.prototype.add = function(entities, attachmentMethod) { if (entities.length) { entities = this._addPage({ values: entities, size: entities.length }, attachmentMethod || 'prepend'); return true; } return false; }; PagedScrollable.prototype.remove = function(entity) { // We only adjust the loadedRange if an idForEntity function // is provided. Otherwise there is the chance that by decrementing // nextPageStart duplicates will appear in the list if (this.options.idForEntity) { var id = this.options.idForEntity(entity); if (_.has(this._ids, id)) { delete this._ids[id]; if (typeof this.loadedRange.nextPageStart === 'number') { this.loadedRange.nextPageStart = Math.max(0, this.loadedRange.nextPageStart - 1); } return true; } } return false; }; PagedScrollable.prototype.attachNewContent = function(data, attachmentMethod) { throw new Error('attachNewContent is abstract and must be implemented.'); }; PagedScrollable.prototype.requestData = function(start, limit) { throw new Error('requestData is abstract and must be implemented. It must return a promise. It is preferred to return a jqXHR.'); }; PagedScrollable.prototype.handleErrors = function(errors) { throw new Error('handleErrors is abstract and must be implemented.'); }; return PagedScrollable; });