// jquery.pjax.js // copyright chris wanstrath // https://github.com/defunkt/jquery-pjax (function($){ // When called on a container with a selector, fetches the href with // ajax into the container or with the data-pjax attribute on the link // itself. // // Tries to make sure the back button and ctrl+click work the way // you'd expect. // // Exported as $.fn.pjax // // Accepts a jQuery ajax options object that may include these // pjax specific options: // // // container - Where to stick the response body. Usually a String selector. // $(container).html(xhr.responseBody) // (default: current jquery context) // push - Whether to pushState the URL. Defaults to true (of course). // replace - Want to use replaceState instead? That's cool. // // For convenience the second parameter can be either the container or // the options object. // // Returns the jQuery object function fnPjax(selector, container, options) { var context = this return this.on('click.pjax', selector, function(event) { var opts = $.extend({}, optionsFor(container, options)) if (!opts.container) opts.container = $(this).attr('data-pjax') || context handleClick(event, opts) }) } // Public: pjax on click handler // // Exported as $.pjax.click. // // event - "click" jQuery.Event // options - pjax options // // Examples // // $(document).on('click', 'a', $.pjax.click) // // is the same as // $(document).pjax('a') // // $(document).on('click', 'a', function(event) { // var container = $(this).closest('[data-pjax-container]') // $.pjax.click(event, container) // }) // // Returns nothing. function handleClick(event, container, options) { options = optionsFor(container, options) var link = event.currentTarget if (link.tagName.toUpperCase() !== 'A') throw "$.fn.pjax or $.pjax.click requires an anchor element" // Middle click, cmd click, and ctrl click should open // links in a new tab as normal. if ( event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) return // Ignore cross origin links if ( location.protocol !== link.protocol || location.hostname !== link.hostname ) return // Ignore anchors on the same page if (link.hash && link.href.replace(link.hash, '') === location.href.replace(location.hash, '')) return // Ignore empty anchor "foo.html#" if (link.href === location.href + '#') return var defaults = { url: link.href, container: $(link).attr('data-pjax'), target: link, fragment: null } var opts = $.extend({}, defaults, options) var clickEvent = $.Event('pjax:click') $(link).trigger(clickEvent, [opts]) if (!clickEvent.isDefaultPrevented()) { pjax(opts) event.preventDefault() } } // Public: pjax on form submit handler // // Exported as $.pjax.submit // // event - "click" jQuery.Event // options - pjax options // // Examples // // $(document).on('submit', 'form', function(event) { // var container = $(this).closest('[data-pjax-container]') // $.pjax.submit(event, container) // }) // // Returns nothing. function handleSubmit(event, container, options) { options = optionsFor(container, options) var form = event.currentTarget if (form.tagName.toUpperCase() !== 'FORM') throw "$.pjax.submit requires a form element" var defaults = { type: form.method.toUpperCase(), url: form.action, data: $(form).serializeArray(), container: $(form).attr('data-pjax'), target: form, fragment: null } pjax($.extend({}, defaults, options)) event.preventDefault() } // Loads a URL with ajax, puts the response body inside a container, // then pushState()'s the loaded URL. // // Works just like $.ajax in that it accepts a jQuery ajax // settings object (with keys like url, type, data, etc). // // Accepts these extra keys: // // container - Where to stick the response body. // $(container).html(xhr.responseBody) // push - Whether to pushState the URL. Defaults to true (of course). // replace - Want to use replaceState instead? That's cool. // // Use it just like $.ajax: // // var xhr = $.pjax({ url: this.href, container: '#main' }) // console.log( xhr.readyState ) // // Returns whatever $.ajax returns. function pjax(options) { options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options) if ($.isFunction(options.url)) { options.url = options.url() } var target = options.target var hash = parseURL(options.url).hash var context = options.context = findContainerFor(options.container) // We want the browser to maintain two separate internal caches: one // for pjax'd partial page loads and one for normal page loads. // Without adding this secret parameter, some browsers will often // confuse the two. if (!options.data) options.data = {} options.data._pjax = context.selector function fire(type, args) { var event = $.Event(type, { relatedTarget: target }) context.trigger(event, args) return !event.isDefaultPrevented() } var timeoutTimer options.beforeSend = function(xhr, settings) { // No timeout for non-GET requests // Its not safe to request the resource again with a fallback method. if (settings.type !== 'GET') { settings.timeout = 0 } xhr.setRequestHeader('X-PJAX', 'true') xhr.setRequestHeader('X-PJAX-Container', context.selector) if (!fire('pjax:beforeSend', [xhr, settings])) return false if (settings.timeout > 0) { timeoutTimer = setTimeout(function() { if (fire('pjax:timeout', [xhr, options])) xhr.abort('timeout') }, settings.timeout) // Clear timeout setting so jquerys internal timeout isn't invoked settings.timeout = 0 } options.requestUrl = parseURL(settings.url).href } options.complete = function(xhr, textStatus) { if (timeoutTimer) clearTimeout(timeoutTimer) fire('pjax:complete', [xhr, textStatus, options]) fire('pjax:end', [xhr, options]) } options.error = function(xhr, textStatus, errorThrown) { var container = extractContainer("", xhr, options) var allowed = fire('pjax:error', [xhr, textStatus, errorThrown, options]) if (options.type == 'GET' && textStatus !== 'abort' && allowed) { locationReplace(container.url) } } options.success = function(data, status, xhr) { // If $.pjax.defaults.version is a function, invoke it first. // Otherwise it can be a static string. var currentVersion = (typeof $.pjax.defaults.version === 'function') ? $.pjax.defaults.version() : $.pjax.defaults.version var latestVersion = xhr.getResponseHeader('X-PJAX-Version') var container = extractContainer(data, xhr, options) // If there is a layout version mismatch, hard load the new url if (currentVersion && latestVersion && currentVersion !== latestVersion) { locationReplace(container.url) return } // If the new response is missing a body, hard load the page if (!container.contents) { locationReplace(container.url) return } pjax.state = { id: options.id || uniqueId(), url: container.url, title: container.title, container: context.selector, fragment: options.fragment, timeout: options.timeout } if (options.push || options.replace) { window.history.replaceState(pjax.state, container.title, container.url) } if (container.title) document.title = container.title context.html(container.contents) executeScriptTags(container.scripts) // Scroll to top by default if (typeof options.scrollTo === 'number') $(window).scrollTop(options.scrollTo) // If the URL has a hash in it, make sure the browser // knows to navigate to the hash. if ( hash !== '' ) { // Avoid using simple hash set here. Will add another history // entry. Replace the url with replaceState and scroll to target // by hand. // // window.location.hash = hash var url = parseURL(container.url) url.hash = hash pjax.state.url = url.href window.history.replaceState(pjax.state, container.title, url.href) var target = $(url.hash) if (target.length) $(window).scrollTop(target.offset().top) } fire('pjax:success', [data, status, xhr, options]) } // Initialize pjax.state for the initial page load. Assume we're // using the container and options of the link we're loading for the // back button to the initial page. This ensures good back button // behavior. if (!pjax.state) { pjax.state = { id: uniqueId(), url: window.location.href, title: document.title, container: context.selector, fragment: options.fragment, timeout: options.timeout } window.history.replaceState(pjax.state, document.title) } // Cancel the current request if we're already pjaxing var xhr = pjax.xhr if ( xhr && xhr.readyState < 4) { xhr.onreadystatechange = $.noop xhr.abort() } pjax.options = options var xhr = pjax.xhr = $.ajax(options) if (xhr.readyState > 0) { if (options.push && !options.replace) { // Cache current container element before replacing it cachePush(pjax.state.id, context.clone().contents()) window.history.pushState(null, "", stripPjaxParam(options.requestUrl)) } fire('pjax:start', [xhr, options]) fire('pjax:send', [xhr, options]) } return pjax.xhr } // Public: Reload current page with pjax. // // Returns whatever $.pjax returns. function pjaxReload(container, options) { var defaults = { url: window.location.href, push: false, replace: true, scrollTo: false } return pjax($.extend(defaults, optionsFor(container, options))) } // Internal: Hard replace current state with url. // // Work for around WebKit // https://bugs.webkit.org/show_bug.cgi?id=93506 // // Returns nothing. function locationReplace(url) { window.history.replaceState(null, "", "#") window.location.replace(url) } var initialPop = true var initialURL = window.location.href var initialState = window.history.state // Initialize $.pjax.state if possible // Happens when reloading a page and coming forward from a different // session history. if (initialState && initialState.container) { pjax.state = initialState } // Non-webkit browsers don't fire an initial popstate event if ('state' in window.history) { initialPop = false } // popstate handler takes care of the back and forward buttons // // You probably shouldn't use pjax on pages with other pushState // stuff yet. function onPjaxPopstate(event) { var state = event.state if (state && state.container) { // When coming forward from a seperate history session, will get an // initial pop with a state we are already at. Skip reloading the current // page. if (initialPop && initialURL == state.url) return var container = $(state.container) if (container.length) { var direction, contents = cacheMapping[state.id] if (pjax.state) { // Since state ids always increase, we can deduce the history // direction from the previous state. direction = pjax.state.id < state.id ? 'forward' : 'back' // Cache current container before replacement and inform the // cache which direction the history shifted. cachePop(direction, pjax.state.id, container.clone().contents()) } var popstateEvent = $.Event('pjax:popstate', { state: state, direction: direction }) container.trigger(popstateEvent) var options = { id: state.id, url: state.url, container: container, push: false, fragment: state.fragment, timeout: state.timeout, scrollTo: false } if (contents) { container.trigger('pjax:start', [null, options]) if (state.title) document.title = state.title container.html(contents) pjax.state = state container.trigger('pjax:end', [null, options]) } else { pjax(options) } // Force reflow/relayout before the browser tries to restore the // scroll position. container[0].offsetHeight } else { locationReplace(location.href) } } initialPop = false } // Fallback version of main pjax function for browsers that don't // support pushState. // // Returns nothing since it retriggers a hard form submission. function fallbackPjax(options) { var url = $.isFunction(options.url) ? options.url() : options.url, method = options.type ? options.type.toUpperCase() : 'GET' var form = $('
', { method: method === 'GET' ? 'GET' : 'POST', action: url, style: 'display:none' }) if (method !== 'GET' && method !== 'POST') { form.append($('', { type: 'hidden', name: '_method', value: method.toLowerCase() })) } var data = options.data if (typeof data === 'string') { $.each(data.split('&'), function(index, value) { var pair = value.split('=') form.append($('', {type: 'hidden', name: pair[0], value: pair[1]})) }) } else if (typeof data === 'object') { for (key in data) form.append($('', {type: 'hidden', name: key, value: data[key]})) } $(document.body).append(form) form.submit() } // Internal: Generate unique id for state object. // // Use a timestamp instead of a counter since ids should still be // unique across page loads. // // Returns Number. function uniqueId() { return (new Date).getTime() } // Internal: Strips _pjax param from url // // url - String // // Returns String. function stripPjaxParam(url) { return url .replace(/\?_pjax=[^&]+&?/, '?') .replace(/_pjax=[^&]+&?/, '') .replace(/[\?&]$/, '') } // Internal: Parse URL components and returns a Locationish object. // // url - String URL // // Returns HTMLAnchorElement that acts like Location. function parseURL(url) { var a = document.createElement('a') a.href = url return a } // Internal: Build options Object for arguments. // // For convenience the first parameter can be either the container or // the options object. // // Examples // // optionsFor('#container') // // => {container: '#container'} // // optionsFor('#container', {push: true}) // // => {container: '#container', push: true} // // optionsFor({container: '#container', push: true}) // // => {container: '#container', push: true} // // Returns options Object. function optionsFor(container, options) { // Both container and options if ( container && options ) options.container = container // First argument is options Object else if ( $.isPlainObject(container) ) options = container // Only container else options = {container: container} // Find and validate container if (options.container) options.container = findContainerFor(options.container) return options } // Internal: Find container element for a variety of inputs. // // Because we can't persist elements using the history API, we must be // able to find a String selector that will consistently find the Element. // // container - A selector String, jQuery object, or DOM Element. // // Returns a jQuery object whose context is `document` and has a selector. function findContainerFor(container) { container = $(container) if ( !container.length ) { throw "no pjax container for " + container.selector } else if ( container.selector !== '' && container.context === document ) { return container } else if ( container.attr('id') ) { return $('#' + container.attr('id')) } else { throw "cant get selector for pjax container!" } } // Internal: Filter and find all elements matching the selector. // // Where $.fn.find only matches descendants, findAll will test all the // top level elements in the jQuery object as well. // // elems - jQuery object of Elements // selector - String selector to match // // Returns a jQuery object. function findAll(elems, selector) { return elems.filter(selector).add(elems.find(selector)); } function parseHTML(html) { return $.parseHTML(html, document, true) } // Internal: Extracts container and metadata from response. // // 1. Extracts X-PJAX-URL header if set // 2. Extracts inline tags // 3. Builds response Element and extracts fragment if set // // data - String response data // xhr - XHR response // options - pjax options Object // // Returns an Object with url, title, and contents keys. function extractContainer(data, xhr, options) { var obj = {} // Prefer X-PJAX-URL header if it was set, otherwise fallback to // using the original requested url. obj.url = stripPjaxParam(xhr.getResponseHeader('X-PJAX-URL') || options.requestUrl) // Attempt to parse response html into elements if (/<html/i.test(data)) { var $head = $(parseHTML(data.match(/<head[^>]*>([\s\S.]*)<\/head>/i)[0])) var $body = $(parseHTML(data.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0])) } else { var $head = $body = $(parseHTML(data)) } // If response data is empty, return fast if ($body.length === 0) return obj // If there's a <title> tag in the header, use it as // the page's title. obj.title = findAll($head, 'title').last().text() if (options.fragment) { // If they specified a fragment, look for it in the response // and pull it out. if (options.fragment === 'body') { var $fragment = $body } else { var $fragment = findAll($body, options.fragment).first() } if ($fragment.length) { obj.contents = $fragment.contents() // If there's no title, look for data-title and title attributes // on the fragment if (!obj.title) obj.title = $fragment.attr('title') || $fragment.data('title') } } else if (!/<html/i.test(data)) { obj.contents = $body } // Clean up any <title> tags if (obj.contents) { // Remove any parent title elements obj.contents = obj.contents.not(function() { return $(this).is('title') }) // Then scrub any titles from their descendents obj.contents.find('title').remove() // Gather all script[src] elements obj.scripts = findAll(obj.contents, 'script[src]').remove() obj.contents = obj.contents.not(obj.scripts) } // Trim any whitespace off the title if (obj.title) obj.title = $.trim(obj.title) return obj } // Load an execute scripts using standard script request. // // Avoids jQuery's traditional $.getScript which does a XHR request and // globalEval. // // scripts - jQuery object of script Elements // // Returns nothing. function executeScriptTags(scripts) { if (!scripts) return var existingScripts = $('script[src]') scripts.each(function() { var src = this.src var matchedScripts = existingScripts.filter(function() { return this.src === src }) if (matchedScripts.length) return var script = document.createElement('script') script.type = $(this).attr('type') script.src = $(this).attr('src') document.head.appendChild(script) }) } // Internal: History DOM caching class. var cacheMapping = {} var cacheForwardStack = [] var cacheBackStack = [] // Push previous state id and container contents into the history // cache. Should be called in conjunction with `pushState` to save the // previous container contents. // // id - State ID Number // value - DOM Element to cache // // Returns nothing. function cachePush(id, value) { cacheMapping[id] = value cacheBackStack.push(id) // Remove all entires in forward history stack after pushing // a new page. while (cacheForwardStack.length) delete cacheMapping[cacheForwardStack.shift()] // Trim back history stack to max cache length. while (cacheBackStack.length > pjax.defaults.maxCacheLength) delete cacheMapping[cacheBackStack.shift()] } // Shifts cache from directional history cache. Should be // called on `popstate` with the previous state id and container // contents. // // direction - "forward" or "back" String // id - State ID Number // value - DOM Element to cache // // Returns nothing. function cachePop(direction, id, value) { var pushStack, popStack cacheMapping[id] = value if (direction === 'forward') { pushStack = cacheBackStack popStack = cacheForwardStack } else { pushStack = cacheForwardStack popStack = cacheBackStack } pushStack.push(id) if (id = popStack.pop()) delete cacheMapping[id] } // Public: Find version identifier for the initial page load. // // Returns String version or undefined. function findVersion() { return $('meta').filter(function() { var name = $(this).attr('http-equiv') return name && name.toUpperCase() === 'X-PJAX-VERSION' }).attr('content') } // Install pjax functions on $.pjax to enable pushState behavior. // // Does nothing if already enabled. // // Examples // // $.pjax.enable() // // Returns nothing. function enable() { $.fn.pjax = fnPjax $.pjax = pjax $.pjax.enable = $.noop $.pjax.disable = disable $.pjax.click = handleClick $.pjax.submit = handleSubmit $.pjax.reload = pjaxReload $.pjax.defaults = { timeout: 650, push: true, replace: false, type: 'GET', dataType: 'html', scrollTo: 0, maxCacheLength: 20, version: findVersion } $(window).on('popstate.pjax', onPjaxPopstate) } // Disable pushState behavior. // // This is the case when a browser doesn't support pushState. It is // sometimes useful to disable pushState for debugging on a modern // browser. // // Examples // // $.pjax.disable() // // Returns nothing. function disable() { $.fn.pjax = function() { return this } $.pjax = fallbackPjax $.pjax.enable = enable $.pjax.disable = $.noop $.pjax.click = $.noop $.pjax.submit = $.noop $.pjax.reload = function() { window.location.reload() } $(window).off('popstate.pjax', onPjaxPopstate) } // Add the state property to jQuery's event object so we can use it in // $(window).bind('popstate') if ( $.inArray('state', $.event.props) < 0 ) $.event.props.push('state') // Is pjax supported by this browser? $.support.pjax = window.history && window.history.pushState && window.history.replaceState && // pushState isn't reliable on iOS until 5. !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/) $.support.pjax ? enable() : disable() })(jQuery);