smooth.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. /**
  2. * smoothState.js is a jQuery plugin to stop page load jank.
  3. *
  4. * This jQuery plugin progressively enhances page loads to
  5. * behave more like a single-page application.
  6. *
  7. * @author Miguel Ángel Pérez reachme@miguel-perez.com
  8. * @see https://github.com/miguel-perez/jquery.smoothState.js
  9. *
  10. */
  11. ;(function ( $, window, document, undefined ) {
  12. "use strict";
  13. var
  14. /** Used later to scroll page to the top */
  15. $body = $("html, body"),
  16. /** Used in development mode to console out useful warnings */
  17. consl = (window.console || false),
  18. /** Plugin default options */
  19. defaults = {
  20. /** jquery element string to specify which anchors smoothstate should bind to */
  21. anchors : "a",
  22. /** If set to true, smoothState will prefetch a link's contents on hover */
  23. prefetch : false,
  24. /** A selecor that deinfes with links should be ignored by smoothState */
  25. blacklist : ".no-smoothstate, [target]",
  26. /** If set to true, smoothState will log useful debug information instead of aborting */
  27. development : false,
  28. /** The number of pages smoothState will try to store in memory and not request again */
  29. pageCacheSize : 0,
  30. /** A function that can be used to alter urls before they are used to request content */
  31. alterRequestUrl : function (url) {
  32. return url;
  33. },
  34. /** Run when a link has been activated */
  35. onStart : {
  36. duration: 0,
  37. render: function (url, $container) {
  38. $body.scrollTop(0);
  39. }
  40. },
  41. /** Run if the page request is still pending and onStart has finished animating */
  42. onProgress : {
  43. duration: 0,
  44. render: function (url, $container) {
  45. $body.css("cursor", "wait");
  46. $body.find("a").css("cursor", "wait");
  47. }
  48. },
  49. /** Run when requested content is ready to be injected into the page */
  50. onEnd : {
  51. duration: 0,
  52. render: function (url, $container, $content) {
  53. $body.css("cursor", "auto");
  54. $body.find("a").css("cursor", "auto");
  55. $container.html($content);
  56. }
  57. },
  58. /** Run when content has been injected and all animations are complete */
  59. callback : function(url, $container, $content) {
  60. }
  61. },
  62. /** Utility functions that are decoupled from SmoothState */
  63. utility = {
  64. /**
  65. * Checks to see if the url is external
  66. * @param {string} url - url being evaluated
  67. * @see http://stackoverflow.com/questions/6238351/fastest-way-to-detect-external-urls
  68. *
  69. */
  70. isExternal: function (url) {
  71. var match = url.match(/^([^:\/?#]+:)?(?:\/\/([^\/?#]*))?([^?#]+)?(\?[^#]*)?(#.*)?/);
  72. if (typeof match[1] === "string" && match[1].length > 0 && match[1].toLowerCase() !== window.location.protocol) {
  73. return true;
  74. }
  75. if (typeof match[2] === "string" && match[2].length > 0 && match[2].replace(new RegExp(":(" + {"http:": 80, "https:": 443}[window.location.protocol] + ")?$"), "") !== window.location.host) {
  76. return true;
  77. }
  78. return false;
  79. },
  80. /**
  81. * Checks to see if the url is an internal hash
  82. * @param {string} url - url being evaluated
  83. *
  84. */
  85. isHash: function (url) {
  86. var hasPathname = (url.indexOf(window.location.pathname) > 0) ? true : false,
  87. hasHash = (url.indexOf("#") > 0) ? true : false;
  88. return (hasPathname && hasHash) ? true : false;
  89. },
  90. /**
  91. * Checks to see if we should be loading this URL
  92. * @param {string} url - url being evaluated
  93. * @param {string} blacklist - jquery selector
  94. *
  95. */
  96. shouldLoad: function ($anchor, blacklist) {
  97. var url = $anchor.prop("href");
  98. // URL will only be loaded if it"s not an external link, hash, or blacklisted
  99. return (!utility.isExternal(url) && !utility.isHash(url) && !$anchor.is(blacklist));
  100. },
  101. /**
  102. * Prevents jQuery from stripping elements from $(html)
  103. * @param {string} url - url being evaluated
  104. * @author Ben Alman http://benalman.com/
  105. * @see https://gist.github.com/cowboy/742952
  106. *
  107. */
  108. htmlDoc: function (html) {
  109. var parent,
  110. elems = $(),
  111. matchTag = /<(\/?)(html|head|body|title|base|meta)(\s+[^>]*)?>/ig,
  112. prefix = "ss" + Math.round(Math.random() * 100000),
  113. htmlParsed = html.replace(matchTag, function(tag, slash, name, attrs) {
  114. var obj = {};
  115. if (!slash) {
  116. elems = elems.add("<" + name + "/>");
  117. if (attrs) {
  118. $.each($("<div" + attrs + "/>")[0].attributes, function(i, attr) {
  119. obj[attr.name] = attr.value;
  120. });
  121. }
  122. elems.eq(-1).attr(obj);
  123. }
  124. return "<" + slash + "div" + (slash ? "" : " id='" + prefix + (elems.length - 1) + "'") + ">";
  125. });
  126. // If no placeholder elements were necessary, just return normal
  127. // jQuery-parsed HTML.
  128. if (!elems.length) {
  129. return $(html);
  130. }
  131. // Create parent node if it hasn"t been created yet.
  132. if (!parent) {
  133. parent = $("<div/>");
  134. }
  135. // Create the parent node and append the parsed, place-held HTML.
  136. parent.html(htmlParsed);
  137. // Replace each placeholder element with its intended element.
  138. $.each(elems, function(i) {
  139. var elem = parent.find("#" + prefix + i).before(elems[i]);
  140. elems.eq(i).html(elem.contents());
  141. elem.remove();
  142. });
  143. return parent.children().unwrap();
  144. },
  145. /**
  146. * Resets an object if it has too many properties
  147. *
  148. * This is used to clear the "cache" object that stores
  149. * all of the html. This would prevent the client from
  150. * running out of memory and allow the user to hit the
  151. * server for a fresh copy of the content.
  152. *
  153. * @param {object} obj
  154. * @param {number} cap
  155. *
  156. */
  157. clearIfOverCapacity: function (obj, cap) {
  158. // Polyfill Object.keys if it doesn"t exist
  159. if (!Object.keys) {
  160. Object.keys = function (obj) {
  161. var keys = [],
  162. k;
  163. for (k in obj) {
  164. if (Object.prototype.hasOwnProperty.call(obj, k)) {
  165. keys.push(k);
  166. }
  167. }
  168. return keys;
  169. };
  170. }
  171. if (Object.keys(obj).length > cap) {
  172. obj = {};
  173. }
  174. return obj;
  175. },
  176. /**
  177. * Finds the inner content of an element, by an ID, from a jQuery object
  178. * @param {string} id
  179. * @param {object} $html
  180. *
  181. */
  182. getContentById: function (id, $html) {
  183. $html = ($html instanceof jQuery) ? $html : utility.htmlDoc($html);
  184. var $insideElem = $html.find(id),
  185. updatedContainer = ($insideElem.length) ? $.trim($insideElem.html()) : $html.filter(id).html(),
  186. newContent = (updatedContainer.length) ? $(updatedContainer) : null;
  187. return newContent;
  188. },
  189. /**
  190. * Stores html content as jquery object in given object
  191. * @param {object} object - object contents will be stored into
  192. * @param {string} url - url to be used as the prop
  193. * @param {jquery} html - contents to store
  194. *
  195. */
  196. storePageIn: function (object, url, $html) {
  197. $html = ($html instanceof jQuery) ? $html : utility.htmlDoc($html);
  198. object[url] = { // Content is indexed by the url
  199. status: "loaded",
  200. title: $html.find("title").text(), // Stores the title of the page
  201. html: $html // Stores the contents of the page
  202. };
  203. return object;
  204. },
  205. /**
  206. * Triggers an "allanimationend" event when all animations are complete
  207. * @param {object} $element - jQuery object that should trigger event
  208. * @param {string} resetOn - which other events to trigger allanimationend on
  209. *
  210. */
  211. triggerAllAnimationEndEvent: function ($element, resetOn) {
  212. resetOn = " " + resetOn || "";
  213. var animationCount = 0,
  214. animationstart = "animationstart webkitAnimationStart oanimationstart MSAnimationStart",
  215. animationend = "animationend webkitAnimationEnd oanimationend MSAnimationEnd",
  216. eventname = "allanimationend",
  217. onAnimationStart = function (e) {
  218. if ($(e.delegateTarget).is($element)) {
  219. e.stopPropagation();
  220. animationCount ++;
  221. }
  222. },
  223. onAnimationEnd = function (e) {
  224. if ($(e.delegateTarget).is($element)) {
  225. e.stopPropagation();
  226. animationCount --;
  227. if(animationCount === 0) {
  228. $element.trigger(eventname);
  229. }
  230. }
  231. };
  232. $element.on(animationstart, onAnimationStart);
  233. $element.on(animationend, onAnimationEnd);
  234. $element.on("allanimationend" + resetOn, function(){
  235. animationCount = 0;
  236. utility.redraw($element);
  237. });
  238. },
  239. /** Forces browser to redraw elements */
  240. redraw: function ($element) {
  241. $element.height(0);
  242. setTimeout(function(){$element.height("auto");}, 0);
  243. }
  244. },
  245. /** Handles the popstate event, like when the user hits "back" */
  246. onPopState = function ( e ) {
  247. if(e.state !== null) {
  248. var url = window.location.href,
  249. $page = $("#" + e.state.id),
  250. page = $page.data("smoothState");
  251. if(page.href !== url && !utility.isHash(url)) {
  252. page.load(url, true);
  253. }
  254. }
  255. },
  256. /** Constructor function */
  257. SmoothState = function ( element, options ) {
  258. var
  259. /** Container element smoothState is run on */
  260. $container = $(element),
  261. /** Variable that stores pages after they are requested */
  262. cache = {},
  263. /** Url of the content that is currently displayed */
  264. currentHref = window.location.href,
  265. /**
  266. * Loads the contents of a url into our container
  267. *
  268. * @param {string} url
  269. * @param {bool} isPopped - used to determine if whe should
  270. * add a new item into the history object
  271. *
  272. */
  273. load = function (url, isPopped) {
  274. /** Makes this an optional variable by setting a default */
  275. isPopped = isPopped || false;
  276. var
  277. /** Used to check if the onProgress function has been run */
  278. hasRunCallback = false,
  279. callbBackEnded = false,
  280. /** List of responses for the states of the page request */
  281. responses = {
  282. /** Page is ready, update the content */
  283. loaded: function() {
  284. var eventName = hasRunCallback ? "ss.onProgressEnd" : "ss.onStartEnd";
  285. if(!callbBackEnded || !hasRunCallback) {
  286. $container.one(eventName, function(){
  287. updateContent(url);
  288. });
  289. } else if(callbBackEnded) {
  290. updateContent(url);
  291. }
  292. if(!isPopped) {
  293. window.history.pushState({ id: $container.prop("id") }, cache[url].title, url);
  294. }
  295. },
  296. /** Loading, wait 10 ms and check again */
  297. fetching: function() {
  298. if(!hasRunCallback) {
  299. hasRunCallback = true;
  300. // Run the onProgress callback and set trigger
  301. $container.one("ss.onStartEnd", function(){
  302. options.onProgress.render(url, $container, null);
  303. setTimeout(function(){
  304. $container.trigger("ss.onProgressEnd");
  305. callbBackEnded = true;
  306. }, options.onStart.duration);
  307. });
  308. }
  309. setTimeout(function () {
  310. // Might of been canceled, better check!
  311. if(cache.hasOwnProperty(url)){
  312. responses[cache[url].status]();
  313. }
  314. }, 10);
  315. },
  316. /** Error, abort and redirect */
  317. error: function(){
  318. window.location = url;
  319. }
  320. };
  321. if (!cache.hasOwnProperty(url)) {
  322. fetch(url);
  323. }
  324. // Run the onStart callback and set trigger
  325. options.onStart.render(url, $container, null);
  326. setTimeout(function(){
  327. $container.trigger("ss.onStartEnd");
  328. }, options.onStart.duration);
  329. // Start checking for the status of content
  330. responses[cache[url].status]();
  331. },
  332. /** Updates the contents from cache[url] */
  333. updateContent = function (url) {
  334. // If the content has been requested and is done:
  335. var containerId = "#" + $container.prop("id"),
  336. $content = cache[url] ? utility.getContentById(containerId, cache[url].html) : null;
  337. if($content) {
  338. document.title = cache[url].title;
  339. $container.data("smoothState").href = url;
  340. // Call the onEnd callback and set trigger
  341. options.onEnd.render(url, $container, $content);
  342. $container.one("ss.onEndEnd", function(){
  343. options.callback(url, $container, $content);
  344. });
  345. setTimeout(function(){
  346. $container.trigger("ss.onEndEnd");
  347. }, options.onEnd.duration);
  348. } else if (!$content && options.development && consl) {
  349. // Throw warning to help debug in development mode
  350. consl.warn("No element with an id of " + containerId + " in response from " + url + " in " + cache);
  351. } else {
  352. // No content availble to update with, aborting...
  353. window.location = url;
  354. }
  355. },
  356. /**
  357. * Fetches the contents of a url and stores it in the "cache" varible
  358. * @param {string} url
  359. *
  360. */
  361. fetch = function (url) {
  362. // Don"t fetch we have the content already
  363. if(cache.hasOwnProperty(url)) {
  364. return;
  365. }
  366. cache = utility.clearIfOverCapacity(cache, options.pageCacheSize);
  367. cache[url] = { status: "fetching" };
  368. var requestUrl = options.alterRequestUrl(url) || url,
  369. request = $.ajax(requestUrl);
  370. // Store contents in cache variable if successful
  371. request.success(function (html) {
  372. // Clear cache varible if it"s getting too big
  373. utility.storePageIn(cache, url, html);
  374. $container.data("smoothState").cache = cache;
  375. });
  376. // Mark as error
  377. request.error(function () {
  378. cache[url].status = "error";
  379. });
  380. },
  381. /**
  382. * Binds to the hover event of a link, used for prefetching content
  383. *
  384. * @param {object} event
  385. *
  386. */
  387. hoverAnchor = function (event) {
  388. var $anchor = $(event.currentTarget),
  389. url = $anchor.prop("href");
  390. if (utility.shouldLoad($anchor, options.blacklist)) {
  391. event.stopPropagation();
  392. fetch(url);
  393. }
  394. },
  395. /**
  396. * Binds to the click event of a link, used to show the content
  397. *
  398. * @param {object} event
  399. *
  400. */
  401. clickAnchor = function (event) {
  402. var $anchor = $(event.currentTarget),
  403. url = $anchor.prop("href");
  404. // Ctrl (or Cmd) + click must open a new tab
  405. if (!event.metaKey && !event.ctrlKey && utility.shouldLoad($anchor, options.blacklist)) {
  406. // stopPropagation so that event doesn"t fire on parent containers.
  407. event.stopPropagation();
  408. event.preventDefault();
  409. load(url);
  410. }
  411. },
  412. /**
  413. * Binds all events and inits functionality
  414. *
  415. * @param {object} event
  416. *
  417. */
  418. bindEventHandlers = function ($element) {
  419. //@todo: Handle form submissions
  420. $element.on("click", options.anchors, clickAnchor);
  421. if (options.prefetch) {
  422. $element.on("mouseover touchstart", options.anchors, hoverAnchor);
  423. }
  424. },
  425. /** Used to restart css animations with a class */
  426. toggleAnimationClass = function (classname) {
  427. var classes = $container.addClass(classname).prop("class");
  428. $container.removeClass(classes);
  429. setTimeout(function(){
  430. $container.addClass(classes);
  431. },0);
  432. $container.one("ss.onStartEnd ss.onProgressEnd ss.onEndEnd", function(){
  433. $container.removeClass(classname);
  434. });
  435. };
  436. /** Override defaults with options passed in */
  437. options = $.extend(defaults, options);
  438. /** Sets a default state */
  439. if(window.history.state === null) {
  440. window.history.replaceState({ id: $container.prop("id") }, document.title, currentHref);
  441. }
  442. /** Stores the current page in cache variable */
  443. utility.storePageIn(cache, currentHref, document.documentElement.outerHTML);
  444. /** Bind all of the event handlers on the container, not anchors */
  445. utility.triggerAllAnimationEndEvent($container, "ss.onStartEnd ss.onProgressEnd ss.onEndEnd");
  446. /** Bind all of the event handlers on the container, not anchors */
  447. bindEventHandlers($container);
  448. /** Public methods */
  449. return {
  450. href: currentHref,
  451. cache: cache,
  452. load: load,
  453. fetch: fetch,
  454. toggleAnimationClass: toggleAnimationClass
  455. };
  456. },
  457. /** Returns elements with SmoothState attached to it */
  458. declareSmoothState = function ( options ) {
  459. return this.each(function () {
  460. // Checks to make sure the smoothState element has an id and isn"t already bound
  461. if(this.id && !$.data(this, "smoothState")) {
  462. // Makes public methods available via $("element").data("smoothState");
  463. $.data(this, "smoothState", new SmoothState(this, options));
  464. } else if (!this.id && consl) {
  465. // Throw warning if in development mode
  466. consl.warn("Every smoothState container needs an id but the following one does not have one:", this);
  467. }
  468. });
  469. };
  470. /** Sets the popstate function */
  471. window.onpopstate = onPopState;
  472. /** Makes utility functions public for unit tests */
  473. $.smoothStateUtility = utility;
  474. /** Defines the smoothState plugin */
  475. $.fn.smoothState = declareSmoothState;
  476. })(jQuery, window, document);