diff options
Diffstat (limited to 'plugins/jetpack/modules/infinite-scroll/infinity.js')
-rw-r--r-- | plugins/jetpack/modules/infinite-scroll/infinity.js | 780 |
1 files changed, 780 insertions, 0 deletions
diff --git a/plugins/jetpack/modules/infinite-scroll/infinity.js b/plugins/jetpack/modules/infinite-scroll/infinity.js new file mode 100644 index 00000000..67eed745 --- /dev/null +++ b/plugins/jetpack/modules/infinite-scroll/infinity.js @@ -0,0 +1,780 @@ +( function( $ ) { + // Open closure + // Local vars + var Scroller, ajaxurl, stats, type, text, totop; + + // IE requires special handling + var isIE = -1 != navigator.userAgent.search( 'MSIE' ); + if ( isIE ) { + var IEVersion = navigator.userAgent.match( /MSIE\s?(\d+)\.?\d*;/ ); + var IEVersion = parseInt( IEVersion[ 1 ] ); + } + + // HTTP ajaxurl when site is HTTPS causes Access-Control-Allow-Origin failure in Desktop and iOS Safari + if ( 'https:' == document.location.protocol ) { + infiniteScroll.settings.ajaxurl = infiniteScroll.settings.ajaxurl.replace( + 'http://', + 'https://' + ); + } + + /** + * Loads new posts when users scroll near the bottom of the page. + */ + Scroller = function( settings ) { + var self = this; + + // Initialize our variables + this.id = settings.id; + this.body = $( document.body ); + this.window = $( window ); + this.element = $( '#' + settings.id ); + this.wrapperClass = settings.wrapper_class; + this.ready = true; + this.disabled = false; + this.page = 1; + this.offset = settings.offset; + this.currentday = settings.currentday; + this.order = settings.order; + this.throttle = false; + this.handle = + '<div id="infinite-handle"><span><button>' + + text.replace( '\\', '' ) + + '</button></span></div>'; + this.click_handle = settings.click_handle; + this.google_analytics = settings.google_analytics; + this.history = settings.history; + this.origURL = window.location.href; + this.pageCache = {}; + + // Footer settings + this.footer = $( '#infinite-footer' ); + this.footer.wrap = settings.footer; + + // Core's native MediaElement.js implementation needs special handling + this.wpMediaelement = null; + + // We have two type of infinite scroll + // cases 'scroll' and 'click' + + if ( type == 'scroll' ) { + // Bind refresh to the scroll event + // Throttle to check for such case every 300ms + + // On event the case becomes a fact + this.window.bind( 'scroll.infinity', function() { + this.throttle = true; + } ); + + // Go back top method + self.gotop(); + + setInterval( function() { + if ( this.throttle ) { + // Once the case is the case, the action occurs and the fact is no more + this.throttle = false; + // Reveal or hide footer + self.thefooter(); + // Fire the refresh + self.refresh(); + self.determineURL(); // determine the url + } + }, 250 ); + + // Ensure that enough posts are loaded to fill the initial viewport, to compensate for short posts and large displays. + self.ensureFilledViewport(); + this.body.bind( 'post-load', { self: self }, self.checkViewportOnLoad ); + } else if ( type == 'click' ) { + if ( this.click_handle ) { + this.element.append( this.handle ); + } + + this.body.delegate( '#infinite-handle', 'click.infinity', function() { + // Handle the handle + if ( self.click_handle ) { + $( '#infinite-handle' ).remove(); + } + + // Fire the refresh + self.refresh(); + } ); + } + + // Initialize any Core audio or video players loaded via IS + this.body.bind( 'post-load', { self: self }, self.initializeMejs ); + }; + + /** + * Check whether we should fetch any additional posts. + */ + Scroller.prototype.check = function() { + var container = this.element.offset(); + + // If the container can't be found, stop otherwise errors result + if ( 'object' !== typeof container ) { + return false; + } + + var bottom = this.window.scrollTop() + this.window.height(), + threshold = container.top + this.element.outerHeight( false ) - this.window.height() * 2; + + return bottom > threshold; + }; + + /** + * Renders the results from a successful response. + */ + Scroller.prototype.render = function( response ) { + this.body.addClass( 'infinity-success' ); + + // Check if we can wrap the html + this.element.append( response.html ); + this.body.trigger( 'post-load', response ); + this.ready = true; + }; + + /** + * Returns the object used to query for new posts. + */ + Scroller.prototype.query = function() { + return { + page: this.page + this.offset, // Load the next page. + currentday: this.currentday, + order: this.order, + scripts: window.infiniteScroll.settings.scripts, + styles: window.infiniteScroll.settings.styles, + query_args: window.infiniteScroll.settings.query_args, + query_before: window.infiniteScroll.settings.query_before, + last_post_date: window.infiniteScroll.settings.last_post_date, + }; + }; + + /** + * Scroll back to top. + */ + Scroller.prototype.gotop = function() { + var blog = $( '#infinity-blog-title' ); + + blog.attr( 'title', totop ); + + // Scroll to top on blog title + blog.bind( 'click', function( e ) { + $( 'html, body' ).animate( { scrollTop: 0 }, 'fast' ); + e.preventDefault(); + } ); + }; + + /** + * The infinite footer. + */ + Scroller.prototype.thefooter = function() { + var self = this, + width; + + // Check if we have an id for the page wrapper + if ( $.type( this.footer.wrap ) === 'string' ) { + width = $( 'body #' + this.footer.wrap ).outerWidth( false ); + + // Make the footer match the width of the page + if ( width > 479 ) this.footer.find( '.container' ).css( 'width', width ); + } + + // Reveal footer + if ( this.window.scrollTop() >= 350 ) self.footer.animate( { bottom: 0 }, 'fast' ); + else if ( this.window.scrollTop() < 350 ) self.footer.animate( { bottom: '-50px' }, 'fast' ); + }; + + /** + * Controls the flow of the refresh. Don't mess. + */ + Scroller.prototype.refresh = function() { + var self = this, + query, + jqxhr, + load, + loader, + color, + customized; + + // If we're disabled, ready, or don't pass the check, bail. + if ( this.disabled || ! this.ready || ! this.check() ) return; + + // Let's get going -- set ready to false to prevent + // multiple refreshes from occurring at once. + this.ready = false; + + // Create a loader element to show it's working. + if ( this.click_handle ) { + loader = '<span class="infinite-loader"></span>'; + this.element.append( loader ); + + loader = this.element.find( '.infinite-loader' ); + color = loader.css( 'color' ); + + try { + loader.spin( 'medium-left', color ); + } catch ( error ) {} + } + + // Generate our query vars. + query = $.extend( + { + action: 'infinite_scroll', + }, + this.query() + ); + + // Inject Customizer state. + if ( 'undefined' !== typeof wp && wp.customize && wp.customize.settings.theme ) { + customized = {}; + query.wp_customize = 'on'; + query.theme = wp.customize.settings.theme.stylesheet; + wp.customize.each( function( setting ) { + if ( setting._dirty ) { + customized[ setting.id ] = setting(); + } + } ); + query.customized = JSON.stringify( customized ); + query.nonce = wp.customize.settings.nonce.preview; + } + + // Fire the ajax request. + jqxhr = $.post( infiniteScroll.settings.ajaxurl, query ); + + // Allow refreshes to occur again if an error is triggered. + jqxhr.fail( function() { + if ( self.click_handle ) { + loader.hide(); + } + + self.ready = true; + } ); + + // Success handler + jqxhr.done( function( response ) { + // On success, let's hide the loader circle. + if ( self.click_handle ) { + loader.hide(); + } + + // Check for and parse our response. + if ( ! response || ! response.type ) { + return; + } + + // If we've succeeded... + if ( response.type == 'success' ) { + // If additional scripts are required by the incoming set of posts, parse them + if ( response.scripts ) { + $( response.scripts ).each( function() { + var elementToAppendTo = this.footer ? 'body' : 'head'; + + // Add script handle to list of those already parsed + window.infiniteScroll.settings.scripts.push( this.handle ); + + // Output extra data, if present + if ( this.extra_data ) { + var data = document.createElement( 'script' ), + dataContent = document.createTextNode( + '//<![CDATA[ \n' + this.extra_data + '\n//]]>' + ); + + data.type = 'text/javascript'; + data.appendChild( dataContent ); + + document.getElementsByTagName( elementToAppendTo )[ 0 ].appendChild( data ); + } + + // Build script tag and append to DOM in requested location + var script = document.createElement( 'script' ); + script.type = 'text/javascript'; + script.src = this.src; + script.id = this.handle; + + // If MediaElement.js is loaded in by this set of posts, don't initialize the players a second time as it breaks them all + if ( 'wp-mediaelement' === this.handle ) { + self.body.unbind( 'post-load', self.initializeMejs ); + } + + if ( 'wp-mediaelement' === this.handle && 'undefined' === typeof mejs ) { + self.wpMediaelement = {}; + self.wpMediaelement.tag = script; + self.wpMediaelement.element = elementToAppendTo; + setTimeout( self.maybeLoadMejs.bind( self ), 250 ); + } else { + document.getElementsByTagName( elementToAppendTo )[ 0 ].appendChild( script ); + } + } ); + } + + // If additional stylesheets are required by the incoming set of posts, parse them + if ( response.styles ) { + $( response.styles ).each( function() { + // Add stylesheet handle to list of those already parsed + window.infiniteScroll.settings.styles.push( this.handle ); + + // Build link tag + var style = document.createElement( 'link' ); + style.rel = 'stylesheet'; + style.href = this.src; + style.id = this.handle + '-css'; + + // Destroy link tag if a conditional statement is present and either the browser isn't IE, or the conditional doesn't evaluate true + if ( + this.conditional && + ( ! isIE || ! eval( this.conditional.replace( /%ver/g, IEVersion ) ) ) + ) + var style = false; + + // Append link tag if necessary + if ( style ) document.getElementsByTagName( 'head' )[ 0 ].appendChild( style ); + } ); + } + + // stash the response in the page cache + self.pageCache[ self.page + self.offset ] = response; + + // Increment the page number + self.page++; + + // Record pageview in WP Stats, if available. + if ( stats ) + new Image().src = + document.location.protocol + + '//pixel.wp.com/g.gif?' + + stats + + '&post=0&baba=' + + Math.random(); + + // Add new posts to the postflair object + if ( 'object' == typeof response.postflair && 'object' == typeof WPCOM_sharing_counts ) + WPCOM_sharing_counts = $.extend( WPCOM_sharing_counts, response.postflair ); + + // Render the results + self.render.apply( self, arguments ); + + // If 'click' type and there are still posts to fetch, add back the handle + if ( type == 'click' ) { + if ( response.lastbatch ) { + if ( self.click_handle ) { + $( '#infinite-handle' ).remove(); + // Update body classes + self.body.addClass( 'infinity-end' ).removeClass( 'infinity-success' ); + } else { + self.body.trigger( 'infinite-scroll-posts-end' ); + } + } else { + if ( self.click_handle ) { + self.element.append( self.handle ); + } else { + self.body.trigger( 'infinite-scroll-posts-more' ); + } + } + } else if ( response.lastbatch ) { + self.disabled = true; + self.body.addClass( 'infinity-end' ).removeClass( 'infinity-success' ); + } + + // Update currentday to the latest value returned from the server + if ( response.currentday ) { + self.currentday = response.currentday; + } + + // Fire Google Analytics pageview + if ( self.google_analytics ) { + var ga_url = self.history.path.replace( /%d/, self.page ); + if ( 'object' === typeof _gaq ) { + _gaq.push( [ '_trackPageview', ga_url ] ); + } + if ( 'function' === typeof ga ) { + ga( 'send', 'pageview', ga_url ); + } + } + } + } ); + + return jqxhr; + }; + + /** + * Core's native media player uses MediaElement.js + * The library's size is sufficient that it may not be loaded in time for Core's helper to invoke it, so we need to delay until `mejs` exists. + */ + Scroller.prototype.maybeLoadMejs = function() { + if ( null === this.wpMediaelement ) { + return; + } + + if ( 'undefined' === typeof mejs ) { + setTimeout( this.maybeLoadMejs, 250 ); + } else { + document + .getElementsByTagName( this.wpMediaelement.element )[ 0 ] + .appendChild( this.wpMediaelement.tag ); + this.wpMediaelement = null; + + // Ensure any subsequent IS loads initialize the players + this.body.bind( 'post-load', { self: this }, this.initializeMejs ); + } + }; + + /** + * Initialize the MediaElement.js player for any posts not previously initialized + */ + Scroller.prototype.initializeMejs = function( ev, response ) { + // Are there media players in the incoming set of posts? + if ( + ! response.html || + ( -1 === response.html.indexOf( 'wp-audio-shortcode' ) && + -1 === response.html.indexOf( 'wp-video-shortcode' ) ) + ) { + return; + } + + // Don't bother if mejs isn't loaded for some reason + if ( 'undefined' === typeof mejs ) { + return; + } + + // Adapted from wp-includes/js/mediaelement/wp-mediaelement.js + // Modified to not initialize already-initialized players, as Mejs doesn't handle that well + $( function() { + var settings = {}; + + if ( typeof _wpmejsSettings !== 'undefined' ) { + settings.pluginPath = _wpmejsSettings.pluginPath; + } + + settings.success = function( mejs ) { + var autoplay = mejs.attributes.autoplay && 'false' !== mejs.attributes.autoplay; + if ( 'flash' === mejs.pluginType && autoplay ) { + mejs.addEventListener( + 'canplay', + function() { + mejs.play(); + }, + false + ); + } + }; + + $( '.wp-audio-shortcode, .wp-video-shortcode' ) + .not( '.mejs-container' ) + .mediaelementplayer( settings ); + } ); + }; + + /** + * Trigger IS to load additional posts if the initial posts don't fill the window. + * On large displays, or when posts are very short, the viewport may not be filled with posts, so we overcome this by loading additional posts when IS initializes. + */ + Scroller.prototype.ensureFilledViewport = function() { + var self = this, + windowHeight = self.window.height(), + postsHeight = self.element.height(), + aveSetHeight = 0, + wrapperQty = 0; + + // Account for situations where postsHeight is 0 because child list elements are floated + if ( postsHeight === 0 ) { + $( self.element.selector + ' > li' ).each( function() { + postsHeight += $( this ).height(); + } ); + + if ( postsHeight === 0 ) { + self.body.unbind( 'post-load', self.checkViewportOnLoad ); + return; + } + } + + // Calculate average height of a set of posts to prevent more posts than needed from being loaded. + $( '.' + self.wrapperClass ).each( function() { + aveSetHeight += $( this ).height(); + wrapperQty++; + } ); + + if ( wrapperQty > 0 ) aveSetHeight = aveSetHeight / wrapperQty; + else aveSetHeight = 0; + + // Load more posts if space permits, otherwise stop checking for a full viewport + if ( postsHeight < windowHeight && postsHeight + aveSetHeight < windowHeight ) { + self.ready = true; + self.refresh(); + } else { + self.body.unbind( 'post-load', self.checkViewportOnLoad ); + } + }; + + /** + * Event handler for ensureFilledViewport(), tied to the post-load trigger. + * Necessary to ensure that the variable `this` contains the scroller when used in ensureFilledViewport(). Since this function is tied to an event, `this` becomes the DOM element the event is tied to. + */ + Scroller.prototype.checkViewportOnLoad = function( ev ) { + ev.data.self.ensureFilledViewport(); + }; + + function fullscreenState() { + return document.fullscreenElement || + document.mozFullScreenElement || + document.webkitFullscreenElement || + document.msFullscreenElement + ? 1 + : 0; + } + + var previousFullScrenState = fullscreenState(); + + /** + * Identify archive page that corresponds to majority of posts shown in the current browser window. + */ + Scroller.prototype.determineURL = function() { + var self = this, + windowTop = $( window ).scrollTop(), + windowBottom = windowTop + $( window ).height(), + windowSize = windowBottom - windowTop, + setsInView = [], + setsHidden = [], + pageNum = false, + currentFullScreenState = fullscreenState(); + + // xor - check if the state has changed + if ( previousFullScrenState ^ currentFullScreenState ) { + // If we just switched to/from fullscreen, + // don't do the div clearing/caching or the + // URL setting. Doing so can break video playback + // if the video goes to fullscreen. + + previousFullScrenState = currentFullScreenState; + return; + } + previousFullScrenState = currentFullScreenState; + + // Find out which sets are in view + $( '.' + self.wrapperClass ).each( function() { + var id = $( this ).attr( 'id' ), + setTop = $( this ).offset().top, + setHeight = $( this ).outerHeight( false ), + setBottom = 0, + setPageNum = $( this ).data( 'page-num' ); + + // Account for containers that have no height because their children are floated elements. + if ( 0 === setHeight ) { + $( '> *', this ).each( function() { + setHeight += $( this ).outerHeight( false ); + } ); + } + + // Determine position of bottom of set by adding its height to the scroll position of its top. + setBottom = setTop + setHeight; + + // Populate setsInView object. While this logic could all be combined into a single conditional statement, this is easier to understand. + if ( setTop < windowTop && setBottom > windowBottom ) { + // top of set is above window, bottom is below + setsInView.push( { id: id, top: setTop, bottom: setBottom, pageNum: setPageNum } ); + } else if ( setTop > windowTop && setTop < windowBottom ) { + // top of set is between top (gt) and bottom (lt) + setsInView.push( { id: id, top: setTop, bottom: setBottom, pageNum: setPageNum } ); + } else if ( setBottom > windowTop && setBottom < windowBottom ) { + // bottom of set is between top (gt) and bottom (lt) + setsInView.push( { id: id, top: setTop, bottom: setBottom, pageNum: setPageNum } ); + } else { + setsHidden.push( { id: id, top: setTop, bottom: setBottom, pageNum: setPageNum } ); + } + } ); + + $.each( setsHidden, function() { + var $set = $( '#' + this.id ); + if ( $set.hasClass( 'is--replaced' ) ) { + return; + } + + self.pageCache[ this.pageNum ].html = $set.html(); + + $set + .css( 'min-height', this.bottom - this.top + 'px' ) + .addClass( 'is--replaced' ) + .empty(); + } ); + + $.each( setsInView, function() { + var $set = $( '#' + this.id ); + + if ( $set.hasClass( 'is--replaced' ) ) { + $set.css( 'min-height', '' ).removeClass( 'is--replaced' ); + if ( this.pageNum in self.pageCache ) { + $set.html( self.pageCache[ this.pageNum ].html ); + self.body.trigger( 'post-load', self.pageCache[ this.pageNum ] ); + } + } + } ); + + // Parse number of sets found in view in an attempt to update the URL to match the set that comprises the majority of the window. + if ( 0 == setsInView.length ) { + pageNum = -1; + } else if ( 1 == setsInView.length ) { + var setData = setsInView.pop(); + + // If the first set of IS posts is in the same view as the posts loaded in the template by WordPress, determine how much of the view is comprised of IS-loaded posts + if ( ( windowBottom - setData.top ) / windowSize < 0.5 ) pageNum = -1; + else pageNum = setData.pageNum; + } else { + var majorityPercentageInView = 0; + + // Identify the IS set that comprises the majority of the current window and set the URL to it. + $.each( setsInView, function( i, setData ) { + var topInView = 0, + bottomInView = 0, + percentOfView = 0; + + // Figure percentage of view the current set represents + if ( setData.top > windowTop && setData.top < windowBottom ) + topInView = ( windowBottom - setData.top ) / windowSize; + + if ( setData.bottom > windowTop && setData.bottom < windowBottom ) + bottomInView = ( setData.bottom - windowTop ) / windowSize; + + // Figure out largest percentage of view for current set + if ( topInView >= bottomInView ) percentOfView = topInView; + else if ( bottomInView >= topInView ) percentOfView = bottomInView; + + // Does current set's percentage of view supplant the largest previously-found set? + if ( percentOfView > majorityPercentageInView ) { + pageNum = setData.pageNum; + majorityPercentageInView = percentOfView; + } + } ); + } + + // If a page number could be determined, update the URL + // -1 indicates that the original requested URL should be used. + if ( 'number' == typeof pageNum ) { + self.updateURL( pageNum ); + } + }; + + /** + * Update address bar to reflect archive page URL for a given page number. + * Checks if URL is different to prevent pollution of browser history. + */ + Scroller.prototype.updateURL = function( page ) { + // IE only supports pushState() in v10 and above, so don't bother if those conditions aren't met. + if ( ! window.history.pushState ) { + return; + } + var self = this, + pageSlug = + -1 == page + ? self.origURL + : window.location.protocol + + '//' + + self.history.host + + self.history.path.replace( /%d/, page ) + + self.history.parameters; + + if ( window.location.href != pageSlug ) { + history.pushState( null, null, pageSlug ); + } + }; + + /** + * Pause scrolling. + */ + Scroller.prototype.pause = function() { + this.disabled = true; + }; + + /** + * Resume scrolling. + */ + Scroller.prototype.resume = function() { + this.disabled = false; + }; + + /** + * Ready, set, go! + */ + $( document ).ready( function() { + // Check for our variables + if ( 'object' != typeof infiniteScroll ) return; + + $( document.body ).addClass( infiniteScroll.settings.body_class ); + + // Set ajaxurl (for brevity) + ajaxurl = infiniteScroll.settings.ajaxurl; + + // Set stats, used for tracking stats + stats = infiniteScroll.settings.stats; + + // Define what type of infinity we have, grab text for click-handle + type = infiniteScroll.settings.type; + text = infiniteScroll.settings.text; + totop = infiniteScroll.settings.totop; + + // Initialize the scroller (with the ID of the element from the theme) + infiniteScroll.scroller = new Scroller( infiniteScroll.settings ); + + /** + * Monitor user scroll activity to update URL to correspond to archive page for current set of IS posts + */ + if ( type == 'click' ) { + var timer = null; + $( window ).bind( 'scroll', function() { + // run the real scroll handler once every 250 ms. + if ( timer ) { + return; + } + timer = setTimeout( function() { + infiniteScroll.scroller.determineURL(); + timer = null; + }, 250 ); + } ); + } + + // Integrate with Selective Refresh in the Customizer. + if ( 'undefined' !== typeof wp && wp.customize && wp.customize.selectiveRefresh ) { + /** + * Handle rendering of selective refresh partials. + * + * Make sure that when a partial is rendered, the Jetpack post-load event + * will be triggered so that any dynamic elements will be re-constructed, + * such as ME.js elements, Photon replacements, social sharing, and more. + * Note that this is applying here not strictly to posts being loaded. + * If a widget contains a ME.js element and it is previewed via selective + * refresh, the post-load would get triggered allowing any dynamic elements + * therein to also be re-constructed. + * + * @param {wp.customize.selectiveRefresh.Placement} placement + */ + wp.customize.selectiveRefresh.bind( 'partial-content-rendered', function( placement ) { + var content; + if ( 'string' === typeof placement.addedContent ) { + content = placement.addedContent; + } else if ( placement.container ) { + content = $( placement.container ).html(); + } + + if ( content ) { + $( document.body ).trigger( 'post-load', { html: content } ); + } + } ); + + /* + * Add partials for posts added via infinite scroll. + * + * This is unnecessary when MutationObserver is supported by the browser + * since then this will be handled by Selective Refresh in core. + */ + if ( 'undefined' === typeof MutationObserver ) { + $( document.body ).on( 'post-load', function( e, response ) { + var rootElement = null; + if ( response.html && -1 !== response.html.indexOf( 'data-customize-partial' ) ) { + if ( infiniteScroll.settings.id ) { + rootElement = $( '#' + infiniteScroll.settings.id ); + } + wp.customize.selectiveRefresh.addPartials( rootElement ); + } + } ); + } + } + } ); +} )( jQuery ); // Close closure |