1// jquery.pjax.js 2// copyright chris wanstrath 3// https://github.com/defunkt/jquery-pjax 4 5(function($){ 6 7// When called on a container with a selector, fetches the href with 8// ajax into the container or with the data-pjax attribute on the link 9// itself. 10// 11// Tries to make sure the back button and ctrl+click work the way 12// you'd expect. 13// 14// Exported as $.fn.pjax 15// 16// Accepts a jQuery ajax options object that may include these 17// pjax specific options: 18// 19// 20// container - Where to stick the response body. Usually a String selector. 21// $(container).html(xhr.responseBody) 22// (default: current jquery context) 23// push - Whether to pushState the URL. Defaults to true (of course). 24// replace - Want to use replaceState instead? That's cool. 25// 26// For convenience the second parameter can be either the container or 27// the options object. 28// 29// Returns the jQuery object 30function fnPjax(selector, container, options) { 31 var context = this 32 return this.on('click.pjax', selector, function(event) { 33 var opts = $.extend({}, optionsFor(container, options)) 34 if (!opts.container) 35 opts.container = $(this).attr('data-pjax') || context 36 handleClick(event, opts) 37 }) 38} 39 40// Public: pjax on click handler 41// 42// Exported as $.pjax.click. 43// 44// event - "click" jQuery.Event 45// options - pjax options 46// 47// Examples 48// 49// $(document).on('click', 'a', $.pjax.click) 50// // is the same as 51// $(document).pjax('a') 52// 53// $(document).on('click', 'a', function(event) { 54// var container = $(this).closest('[data-pjax-container]') 55// $.pjax.click(event, container) 56// }) 57// 58// Returns nothing. 59function handleClick(event, container, options) { 60 options = optionsFor(container, options) 61 62 var link = event.currentTarget 63 64 if (link.tagName.toUpperCase() !== 'A') 65 throw "$.fn.pjax or $.pjax.click requires an anchor element" 66 67 // Middle click, cmd click, and ctrl click should open 68 // links in a new tab as normal. 69 if ( event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) 70 return 71 72 // Ignore cross origin links 73 if ( location.protocol !== link.protocol || location.hostname !== link.hostname ) 74 return 75 76 // Ignore anchors on the same page 77 if (link.hash && link.href.replace(link.hash, '') === 78 location.href.replace(location.hash, '')) 79 return 80 81 // Ignore empty anchor "foo.html#" 82 if (link.href === location.href + '#') 83 return 84 85 var defaults = { 86 url: link.href, 87 container: $(link).attr('data-pjax'), 88 target: link, 89 fragment: null 90 } 91 92 var opts = $.extend({}, defaults, options) 93 var clickEvent = $.Event('pjax:click') 94 $(link).trigger(clickEvent, [opts]) 95 96 if (!clickEvent.isDefaultPrevented()) { 97 pjax(opts) 98 event.preventDefault() 99 } 100} 101 102// Public: pjax on form submit handler 103// 104// Exported as $.pjax.submit 105// 106// event - "click" jQuery.Event 107// options - pjax options 108// 109// Examples 110// 111// $(document).on('submit', 'form', function(event) { 112// var container = $(this).closest('[data-pjax-container]') 113// $.pjax.submit(event, container) 114// }) 115// 116// Returns nothing. 117function handleSubmit(event, container, options) { 118 options = optionsFor(container, options) 119 120 var form = event.currentTarget 121 122 if (form.tagName.toUpperCase() !== 'FORM') 123 throw "$.pjax.submit requires a form element" 124 125 var defaults = { 126 type: form.method.toUpperCase(), 127 url: form.action, 128 data: $(form).serializeArray(), 129 container: $(form).attr('data-pjax'), 130 target: form, 131 fragment: null 132 } 133 134 pjax($.extend({}, defaults, options)) 135 136 event.preventDefault() 137} 138 139// Loads a URL with ajax, puts the response body inside a container, 140// then pushState()'s the loaded URL. 141// 142// Works just like $.ajax in that it accepts a jQuery ajax 143// settings object (with keys like url, type, data, etc). 144// 145// Accepts these extra keys: 146// 147// container - Where to stick the response body. 148// $(container).html(xhr.responseBody) 149// push - Whether to pushState the URL. Defaults to true (of course). 150// replace - Want to use replaceState instead? That's cool. 151// 152// Use it just like $.ajax: 153// 154// var xhr = $.pjax({ url: this.href, container: '#main' }) 155// console.log( xhr.readyState ) 156// 157// Returns whatever $.ajax returns. 158function pjax(options) { 159 options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options) 160 161 if ($.isFunction(options.url)) { 162 options.url = options.url() 163 } 164 165 var target = options.target 166 167 var hash = parseURL(options.url).hash 168 169 var context = options.context = findContainerFor(options.container) 170 171 // We want the browser to maintain two separate internal caches: one 172 // for pjax'd partial page loads and one for normal page loads. 173 // Without adding this secret parameter, some browsers will often 174 // confuse the two. 175 if (!options.data) options.data = {} 176 options.data._pjax = context.selector 177 178 function fire(type, args) { 179 var event = $.Event(type, { relatedTarget: target }) 180 context.trigger(event, args) 181 return !event.isDefaultPrevented() 182 } 183 184 var timeoutTimer 185 186 options.beforeSend = function(xhr, settings) { 187 // No timeout for non-GET requests 188 // Its not safe to request the resource again with a fallback method. 189 if (settings.type !== 'GET') { 190 settings.timeout = 0 191 } 192 193 xhr.setRequestHeader('X-PJAX', 'true') 194 xhr.setRequestHeader('X-PJAX-Container', context.selector) 195 196 if (!fire('pjax:beforeSend', [xhr, settings])) 197 return false 198 199 if (settings.timeout > 0) { 200 timeoutTimer = setTimeout(function() { 201 if (fire('pjax:timeout', [xhr, options])) 202 xhr.abort('timeout') 203 }, settings.timeout) 204 205 // Clear timeout setting so jquerys internal timeout isn't invoked 206 settings.timeout = 0 207 } 208 209 options.requestUrl = parseURL(settings.url).href 210 } 211 212 options.complete = function(xhr, textStatus) { 213 if (timeoutTimer) 214 clearTimeout(timeoutTimer) 215 216 fire('pjax:complete', [xhr, textStatus, options]) 217 218 fire('pjax:end', [xhr, options]) 219 } 220 221 options.error = function(xhr, textStatus, errorThrown) { 222 var container = extractContainer("", xhr, options) 223 224 var allowed = fire('pjax:error', [xhr, textStatus, errorThrown, options]) 225 if (options.type == 'GET' && textStatus !== 'abort' && allowed) { 226 locationReplace(container.url) 227 } 228 } 229 230 options.success = function(data, status, xhr) { 231 // If $.pjax.defaults.version is a function, invoke it first. 232 // Otherwise it can be a static string. 233 var currentVersion = (typeof $.pjax.defaults.version === 'function') ? 234 $.pjax.defaults.version() : 235 $.pjax.defaults.version 236 237 var latestVersion = xhr.getResponseHeader('X-PJAX-Version') 238 239 var container = extractContainer(data, xhr, options) 240 241 // If there is a layout version mismatch, hard load the new url 242 if (currentVersion && latestVersion && currentVersion !== latestVersion) { 243 locationReplace(container.url) 244 return 245 } 246 247 // If the new response is missing a body, hard load the page 248 if (!container.contents) { 249 locationReplace(container.url) 250 return 251 } 252 253 pjax.state = { 254 id: options.id || uniqueId(), 255 url: container.url, 256 title: container.title, 257 container: context.selector, 258 fragment: options.fragment, 259 timeout: options.timeout 260 } 261 262 if (options.push || options.replace) { 263 window.history.replaceState(pjax.state, container.title, container.url) 264 } 265 266 if (container.title) document.title = container.title 267 context.html(container.contents) 268 executeScriptTags(container.scripts) 269 270 // Scroll to top by default 271 if (typeof options.scrollTo === 'number') 272 $(window).scrollTop(options.scrollTo) 273 274 // If the URL has a hash in it, make sure the browser 275 // knows to navigate to the hash. 276 if ( hash !== '' ) { 277 // Avoid using simple hash set here. Will add another history 278 // entry. Replace the url with replaceState and scroll to target 279 // by hand. 280 // 281 // window.location.hash = hash 282 var url = parseURL(container.url) 283 url.hash = hash 284 285 pjax.state.url = url.href 286 window.history.replaceState(pjax.state, container.title, url.href) 287 288 var target = $(url.hash) 289 if (target.length) $(window).scrollTop(target.offset().top) 290 } 291 292 fire('pjax:success', [data, status, xhr, options]) 293 } 294 295 296 // Initialize pjax.state for the initial page load. Assume we're 297 // using the container and options of the link we're loading for the 298 // back button to the initial page. This ensures good back button 299 // behavior. 300 if (!pjax.state) { 301 pjax.state = { 302 id: uniqueId(), 303 url: window.location.href, 304 title: document.title, 305 container: context.selector, 306 fragment: options.fragment, 307 timeout: options.timeout 308 } 309 window.history.replaceState(pjax.state, document.title) 310 } 311 312 // Cancel the current request if we're already pjaxing 313 var xhr = pjax.xhr 314 if ( xhr && xhr.readyState < 4) { 315 xhr.onreadystatechange = $.noop 316 xhr.abort() 317 } 318 319 pjax.options = options 320 var xhr = pjax.xhr = $.ajax(options) 321 322 if (xhr.readyState > 0) { 323 if (options.push && !options.replace) { 324 // Cache current container element before replacing it 325 cachePush(pjax.state.id, context.clone().contents()) 326 327 window.history.pushState(null, "", stripPjaxParam(options.requestUrl)) 328 } 329 330 fire('pjax:start', [xhr, options]) 331 fire('pjax:send', [xhr, options]) 332 } 333 334 return pjax.xhr 335} 336 337// Public: Reload current page with pjax. 338// 339// Returns whatever $.pjax returns. 340function pjaxReload(container, options) { 341 var defaults = { 342 url: window.location.href, 343 push: false, 344 replace: true, 345 scrollTo: false 346 } 347 348 return pjax($.extend(defaults, optionsFor(container, options))) 349} 350 351// Internal: Hard replace current state with url. 352// 353// Work for around WebKit 354// https://bugs.webkit.org/show_bug.cgi?id=93506 355// 356// Returns nothing. 357function locationReplace(url) { 358 window.history.replaceState(null, "", "#") 359 window.location.replace(url) 360} 361 362 363var initialPop = true 364var initialURL = window.location.href 365var initialState = window.history.state 366 367// Initialize $.pjax.state if possible 368// Happens when reloading a page and coming forward from a different 369// session history. 370if (initialState && initialState.container) { 371 pjax.state = initialState 372} 373 374// Non-webkit browsers don't fire an initial popstate event 375if ('state' in window.history) { 376 initialPop = false 377} 378 379// popstate handler takes care of the back and forward buttons 380// 381// You probably shouldn't use pjax on pages with other pushState 382// stuff yet. 383function onPjaxPopstate(event) { 384 var state = event.state 385 386 if (state && state.container) { 387 // When coming forward from a seperate history session, will get an 388 // initial pop with a state we are already at. Skip reloading the current 389 // page. 390 if (initialPop && initialURL == state.url) return 391 392 var container = $(state.container) 393 if (container.length) { 394 var direction, contents = cacheMapping[state.id] 395 396 if (pjax.state) { 397 // Since state ids always increase, we can deduce the history 398 // direction from the previous state. 399 direction = pjax.state.id < state.id ? 'forward' : 'back' 400 401 // Cache current container before replacement and inform the 402 // cache which direction the history shifted. 403 cachePop(direction, pjax.state.id, container.clone().contents()) 404 } 405 406 var popstateEvent = $.Event('pjax:popstate', { 407 state: state, 408 direction: direction 409 }) 410 container.trigger(popstateEvent) 411 412 var options = { 413 id: state.id, 414 url: state.url, 415 container: container, 416 push: false, 417 fragment: state.fragment, 418 timeout: state.timeout, 419 scrollTo: false 420 } 421 422 if (contents) { 423 container.trigger('pjax:start', [null, options]) 424 425 if (state.title) document.title = state.title 426 container.html(contents) 427 pjax.state = state 428 429 container.trigger('pjax:end', [null, options]) 430 } else { 431 pjax(options) 432 } 433 434 // Force reflow/relayout before the browser tries to restore the 435 // scroll position. 436 container[0].offsetHeight 437 } else { 438 locationReplace(location.href) 439 } 440 } 441 initialPop = false 442} 443 444// Fallback version of main pjax function for browsers that don't 445// support pushState. 446// 447// Returns nothing since it retriggers a hard form submission. 448function fallbackPjax(options) { 449 var url = $.isFunction(options.url) ? options.url() : options.url, 450 method = options.type ? options.type.toUpperCase() : 'GET' 451 452 var form = $('<form>', { 453 method: method === 'GET' ? 'GET' : 'POST', 454 action: url, 455 style: 'display:none' 456 }) 457 458 if (method !== 'GET' && method !== 'POST') { 459 form.append($('<input>', { 460 type: 'hidden', 461 name: '_method', 462 value: method.toLowerCase() 463 })) 464 } 465 466 var data = options.data 467 if (typeof data === 'string') { 468 $.each(data.split('&'), function(index, value) { 469 var pair = value.split('=') 470 form.append($('<input>', {type: 'hidden', name: pair[0], value: pair[1]})) 471 }) 472 } else if (typeof data === 'object') { 473 for (key in data) 474 form.append($('<input>', {type: 'hidden', name: key, value: data[key]})) 475 } 476 477 $(document.body).append(form) 478 form.submit() 479} 480 481// Internal: Generate unique id for state object. 482// 483// Use a timestamp instead of a counter since ids should still be 484// unique across page loads. 485// 486// Returns Number. 487function uniqueId() { 488 return (new Date).getTime() 489} 490 491// Internal: Strips _pjax param from url 492// 493// url - String 494// 495// Returns String. 496function stripPjaxParam(url) { 497 return url 498 .replace(/\?_pjax=[^&]+&?/, '?') 499 .replace(/_pjax=[^&]+&?/, '') 500 .replace(/[\?&]$/, '') 501} 502 503// Internal: Parse URL components and returns a Locationish object. 504// 505// url - String URL 506// 507// Returns HTMLAnchorElement that acts like Location. 508function parseURL(url) { 509 var a = document.createElement('a') 510 a.href = url 511 return a 512} 513 514// Internal: Build options Object for arguments. 515// 516// For convenience the first parameter can be either the container or 517// the options object. 518// 519// Examples 520// 521// optionsFor('#container') 522// // => {container: '#container'} 523// 524// optionsFor('#container', {push: true}) 525// // => {container: '#container', push: true} 526// 527// optionsFor({container: '#container', push: true}) 528// // => {container: '#container', push: true} 529// 530// Returns options Object. 531function optionsFor(container, options) { 532 // Both container and options 533 if ( container && options ) 534 options.container = container 535 536 // First argument is options Object 537 else if ( $.isPlainObject(container) ) 538 options = container 539 540 // Only container 541 else 542 options = {container: container} 543 544 // Find and validate container 545 if (options.container) 546 options.container = findContainerFor(options.container) 547 548 return options 549} 550 551// Internal: Find container element for a variety of inputs. 552// 553// Because we can't persist elements using the history API, we must be 554// able to find a String selector that will consistently find the Element. 555// 556// container - A selector String, jQuery object, or DOM Element. 557// 558// Returns a jQuery object whose context is `document` and has a selector. 559function findContainerFor(container) { 560 container = $(container) 561 562 if ( !container.length ) { 563 throw "no pjax container for " + container.selector 564 } else if ( container.selector !== '' && container.context === document ) { 565 return container 566 } else if ( container.attr('id') ) { 567 return $('#' + container.attr('id')) 568 } else { 569 throw "cant get selector for pjax container!" 570 } 571} 572 573// Internal: Filter and find all elements matching the selector. 574// 575// Where $.fn.find only matches descendants, findAll will test all the 576// top level elements in the jQuery object as well. 577// 578// elems - jQuery object of Elements 579// selector - String selector to match 580// 581// Returns a jQuery object. 582function findAll(elems, selector) { 583 return elems.filter(selector).add(elems.find(selector)); 584} 585 586function parseHTML(html) { 587 return $.parseHTML(html, document, true) 588} 589 590// Internal: Extracts container and metadata from response. 591// 592// 1. Extracts X-PJAX-URL header if set 593// 2. Extracts inline <title> tags 594// 3. Builds response Element and extracts fragment if set 595// 596// data - String response data 597// xhr - XHR response 598// options - pjax options Object 599// 600// Returns an Object with url, title, and contents keys. 601function extractContainer(data, xhr, options) { 602 var obj = {} 603 604 // Prefer X-PJAX-URL header if it was set, otherwise fallback to 605 // using the original requested url. 606 obj.url = stripPjaxParam(xhr.getResponseHeader('X-PJAX-URL') || options.requestUrl) 607 608 // Attempt to parse response html into elements 609 if (/<html/i.test(data)) { 610 var $head = $(parseHTML(data.match(/<head[^>]*>([\s\S.]*)<\/head>/i)[0])) 611 var $body = $(parseHTML(data.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0])) 612 } else { 613 var $head = $body = $(parseHTML(data)) 614 } 615 616 // If response data is empty, return fast 617 if ($body.length === 0) 618 return obj 619 620 // If there's a <title> tag in the header, use it as 621 // the page's title. 622 obj.title = findAll($head, 'title').last().text() 623 624 if (options.fragment) { 625 // If they specified a fragment, look for it in the response 626 // and pull it out. 627 if (options.fragment === 'body') { 628 var $fragment = $body 629 } else { 630 var $fragment = findAll($body, options.fragment).first() 631 } 632 633 if ($fragment.length) { 634 obj.contents = $fragment.contents() 635 636 // If there's no title, look for data-title and title attributes 637 // on the fragment 638 if (!obj.title) 639 obj.title = $fragment.attr('title') || $fragment.data('title') 640 } 641 642 } else if (!/<html/i.test(data)) { 643 obj.contents = $body 644 } 645 646 // Clean up any <title> tags 647 if (obj.contents) { 648 // Remove any parent title elements 649 obj.contents = obj.contents.not(function() { return $(this).is('title') }) 650 651 // Then scrub any titles from their descendents 652 obj.contents.find('title').remove() 653 654 // Gather all script[src] elements 655 obj.scripts = findAll(obj.contents, 'script[src]').remove() 656 obj.contents = obj.contents.not(obj.scripts) 657 } 658 659 // Trim any whitespace off the title 660 if (obj.title) obj.title = $.trim(obj.title) 661 662 return obj 663} 664 665// Load an execute scripts using standard script request. 666// 667// Avoids jQuery's traditional $.getScript which does a XHR request and 668// globalEval. 669// 670// scripts - jQuery object of script Elements 671// 672// Returns nothing. 673function executeScriptTags(scripts) { 674 if (!scripts) return 675 676 var existingScripts = $('script[src]') 677 678 scripts.each(function() { 679 var src = this.src 680 var matchedScripts = existingScripts.filter(function() { 681 return this.src === src 682 }) 683 if (matchedScripts.length) return 684 685 var script = document.createElement('script') 686 script.type = $(this).attr('type') 687 script.src = $(this).attr('src') 688 document.head.appendChild(script) 689 }) 690} 691 692// Internal: History DOM caching class. 693var cacheMapping = {} 694var cacheForwardStack = [] 695var cacheBackStack = [] 696 697// Push previous state id and container contents into the history 698// cache. Should be called in conjunction with `pushState` to save the 699// previous container contents. 700// 701// id - State ID Number 702// value - DOM Element to cache 703// 704// Returns nothing. 705function cachePush(id, value) { 706 cacheMapping[id] = value 707 cacheBackStack.push(id) 708 709 // Remove all entires in forward history stack after pushing 710 // a new page. 711 while (cacheForwardStack.length) 712 delete cacheMapping[cacheForwardStack.shift()] 713 714 // Trim back history stack to max cache length. 715 while (cacheBackStack.length > pjax.defaults.maxCacheLength) 716 delete cacheMapping[cacheBackStack.shift()] 717} 718 719// Shifts cache from directional history cache. Should be 720// called on `popstate` with the previous state id and container 721// contents. 722// 723// direction - "forward" or "back" String 724// id - State ID Number 725// value - DOM Element to cache 726// 727// Returns nothing. 728function cachePop(direction, id, value) { 729 var pushStack, popStack 730 cacheMapping[id] = value 731 732 if (direction === 'forward') { 733 pushStack = cacheBackStack 734 popStack = cacheForwardStack 735 } else { 736 pushStack = cacheForwardStack 737 popStack = cacheBackStack 738 } 739 740 pushStack.push(id) 741 if (id = popStack.pop()) 742 delete cacheMapping[id] 743} 744 745// Public: Find version identifier for the initial page load. 746// 747// Returns String version or undefined. 748function findVersion() { 749 return $('meta').filter(function() { 750 var name = $(this).attr('http-equiv') 751 return name && name.toUpperCase() === 'X-PJAX-VERSION' 752 }).attr('content') 753} 754 755// Install pjax functions on $.pjax to enable pushState behavior. 756// 757// Does nothing if already enabled. 758// 759// Examples 760// 761// $.pjax.enable() 762// 763// Returns nothing. 764function enable() { 765 $.fn.pjax = fnPjax 766 $.pjax = pjax 767 $.pjax.enable = $.noop 768 $.pjax.disable = disable 769 $.pjax.click = handleClick 770 $.pjax.submit = handleSubmit 771 $.pjax.reload = pjaxReload 772 $.pjax.defaults = { 773 timeout: 1050, 774 push: true, 775 replace: false, 776 type: 'GET', 777 dataType: 'html', 778 scrollTo: 0, 779 maxCacheLength: 20, 780 version: findVersion 781 } 782 $(window).on('popstate.pjax', onPjaxPopstate) 783} 784 785// Disable pushState behavior. 786// 787// This is the case when a browser doesn't support pushState. It is 788// sometimes useful to disable pushState for debugging on a modern 789// browser. 790// 791// Examples 792// 793// $.pjax.disable() 794// 795// Returns nothing. 796function disable() { 797 $.fn.pjax = function() { return this } 798 $.pjax = fallbackPjax 799 $.pjax.enable = enable 800 $.pjax.disable = $.noop 801 $.pjax.click = $.noop 802 $.pjax.submit = $.noop 803 $.pjax.reload = function() { window.location.reload() } 804 805 $(window).off('popstate.pjax', onPjaxPopstate) 806} 807 808 809// Add the state property to jQuery's event object so we can use it in 810// $(window).bind('popstate') 811if ( $.inArray('state', $.event.props) < 0 ) 812 $.event.props.push('state') 813 814 // Is pjax supported by this browser? 815$.support.pjax = 816 window.history && window.history.pushState && window.history.replaceState && 817 // pushState isn't reliable on iOS until 5. 818 !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/) 819 820$.support.pjax ? enable() : disable() 821 822})(jQuery); 823