/** * History.js HTML5 Support * @author Benjamin Arthur Lupton * @copyright 2010-2011 Benjamin Arthur Lupton * @license New BSD License */ (function(window, undefined) { // -------------------------------------------------------------------------- // Initialise // History Object window.History = window.History || {}; window._History = window._History || {}; // Localise Globals var console = window.console || undefined, // Prevent a JSLint complain document = window.document, // Make sure we are using the correct document _History = window._History, // Private History Object History = window.History, // Public History Object history = window.history; // Old History Object // Check Existence of History.js if (typeof History.initHtml5 !== 'undefined') { throw new Error('History.js HTML5 Support has already been loaded...'); } // Initialise History.initHtml5 = function() { // ---------------------------------------------------------------------- // Check Status if (typeof History.Adapter === 'undefined') { return false; } // ---------------------------------------------------------------------- // Debug Helpers /** * History.options * Configurable options */ History.options = { /** * History.options.hashChangeCheckerDelay * How long should the interval be before hashchange checks */ hashChangeCheckerDelay: 100, /** * History.options.busyDelay * How long should we wait between busy events */ busyDelay: 250 }; // ---------------------------------------------------------------------- // Debug Helpers /** * History.debug(message,...) * Logs the passed arguments if debug enabled */ History.debug = function() { if ((History.debug.enable || false)) { History.log.apply(History, arguments); } }; History.debug.enable = false; /** * History.log(message,...) * Logs the passed arguments */ History.log = function() { // Prepare var consoleExists = (typeof console !== 'undefined'), textarea = document.getElementById('log'), message = ("\n" + arguments[0] + "\n"), i ; // Write to Console if (consoleExists) { var args = Array.prototype.slice.call(arguments), message = args.shift(); if (typeof console.debug !== 'undefined') { console.debug.apply(console, [message,args]); } else { console.log.apply(console, [message,args]); } } // Write to log for (i = 1,n = arguments.length; i < n; ++i) { var arg = arguments[i]; if (typeof arg === 'object' && typeof JSON !== 'undefined') { try { arg = JSON.stringify(arg); } catch (Exception) { // Recursive Object } } message += "\n" + arg + "\n"; } // Textarea if (textarea) { textarea.value += message + "\n-----\n"; textarea.scrollTop = textarea.scrollHeight - textarea.clientHeight; } // No Textarea, No Console else if (!consoleExists) { alert(message); } // Return true return true; }; // ---------------------------------------------------------------------- // Emulated Status /** * _History.getInternetExplorerMajorVersion() * Get's the major version of Internet Explorer * @return {integer} * @license Public Domain * @author Benjamin Arthur Lupton * @author James Padolsey */ _History.getInternetExplorerMajorVersion = function() { var result = _History.getInternetExplorerMajorVersion.cached = (typeof _History.getInternetExplorerMajorVersion.cached !== 'undefined') ? _History.getInternetExplorerMajorVersion.cached : (function() { var undef, v = 3, div = document.createElement('div'), all = div.getElementsByTagName('i'); while ( div.innerHTML = '', all[0] ); return v > 4 ? v : undef; })() ; return result; }; /** * _History.isInternetExplorer() * Are we using Internet Explorer? * @return {boolean} * @license Public Domain * @author Benjamin Arthur Lupton */ _History.isInternetExplorer = function() { var result = _History.isInternetExplorer.cached = (typeof _History.isInternetExplorer.cached !== 'undefined') ? _History.isInternetExplorer.cached : (_History.getInternetExplorerMajorVersion() !== 0) ; return result; }; /** * History.emulated * Which features require emulating? */ History.emulated = { pushState: !Boolean(window.history && window.history.pushState && window.history.replaceState) }; /** * _History.isEmptyObject(obj) * Checks to see if the Object is Empty * @param {Object} obj * @return {boolean} */ _History.isEmptyObject = function(obj) { for (var key in obj) { if (!this.hasOwnProperty(key)) { continue; } return false; } return true; }; /** * _History.cloneObject(obj) * Clones a object * @param {Object} obj * @return {Object} */ _History.cloneObject = function(obj) { var hash,newObj; if (obj) { hash = JSON.stringify(obj); newObj = JSON.parse(hash); } else { newObj = {}; } return newObj; }; // ---------------------------------------------------------------------- // State Object Helpers /** * History.contractUrl(url) * Ensures that we have a relative URL and not a absolute URL * @param {string} url * @return {string} url */ History.contractUrl = function(url) { // Prepare url = History.expandUrl(url); // Prepare for Base Domain var baseDomain = document.location.protocol + '//' + (document.location.hostname || document.location.host); if (document.location.port || false) { baseDomain += ':' + document.location.port; } baseDomain += '/'; // Adjust for Base Domain url = url.replace(baseDomain, '/'); // Return url return url; }; /** * History.expandUrl(url) * Ensures that we have an absolute URL and not a relative URL * @param {string} url * @return {string} url */ History.expandUrl = function(url) { // Prepare url = url || ''; // Test for Full URL if (/[a-z]+\:\/\//.test(url)) { // We have a Full URL } // Relative URL else { // Test for Base Page if (url.length === 0 || url.substring(0, 1) === '?') { // Fetch Base Page var basePage = document.location.href.replace(/[#\?].*/, ''); // Adjust Page url = basePage + url; } // No Base Page else { // Prepare for Base Element var baseElements = document.getElementsByTagName('base'), baseElement = null, baseHref = ''; // Test for Base Element if (baseElements.length === 1) { // Prepare for Base Element baseElement = baseElements[0]; baseHref = baseElement.href; if (baseHref[baseHref.length - 1] !== '/') baseHref += '/'; // Adjust for Base Element url = baseHref + url.replace(/^\//, ''); } // No Base Element else { // Test for Base URL if (url.substring(0, 1) === '.') { // Prepare for Base URL var baseUrl = document.location.href.replace(/[#\?].*/, '').replace(/[^\/]+$/, ''); if (baseUrl[baseUrl.length - 1] !== '/') baseUrl += '/'; // Adjust for Base URL url = baseUrl + url; } // No Base URL else { // Prepare for Base Domain var baseDomain = document.location.protocol + '//' + (document.location.hostname || document.location.host); if (document.location.port || false) { baseDomain += ':' + document.location.port; } baseDomain += '/'; // Adjust for Base Domain url = baseDomain + url.replace(/^\//, ''); } } } } // Return url return url; }; /** * History.expandState(State) * Expands a State Object * @param {object} State * @return {object} */ History.expandState = function(oldState) { oldState = oldState || {}; var newState = { 'data': oldState.data || {}, 'url': History.expandUrl(oldState.url || ''), 'title': oldState.title || '' }; newState.data.title = newState.data.title || newState.title; newState.data.url = newState.data.url || newState.url; return newState; }; /** * History.createStateObject(data,title,url) * Creates a object based on the data, title and url state params * @param {object} data * @param {string} title * @param {string} url * @return {object} */ History.createStateObject = function(data, title, url) { // Hashify var State = { 'data': data, 'title': title, 'url': url }; // Expand the State State = History.expandState(State); // Return object return State; }; /** * History.expandHash(hash) * Expands a Hash into a StateHash if applicable * @param {string} hash * @return {Object|null} State */ History.expandHash = function(hash) { // Prepare var State = null; // JSON try { State = JSON.parse(hash); } catch (Exception) { var parts = /(.*)\/uid=([0-9]+)$/.exec(hash), url = parts ? (parts[1] || hash) : hash, uid = parts ? String(parts[2] || '') : ''; if (uid) { State = _History.getStateByUid(uid) || null; } if (!State && /\//.test(hash)) { // Is a URL var expandedUrl = History.expandUrl(hash); State = History.createStateObject(null, null, expandedUrl); } else { // Non State Hash // do nothing } } // Expand State = State ? History.expandState(State) : null; // Return State return State; }; /** * History.contractState(State) * Creates a Hash for the State Object * @param {object} passedState * @return {string} hash */ History.contractState = function(passedState) { // Check if (!passedState) { return null; } // Prepare var hash = null, State = _History.cloneObject(passedState); // Ensure State if (State) { // Clean State.data = State.data || {}; delete State.data.title; delete State.data.url; // Handle if (_History.isEmptyObject(State) && !State.title) { hash = History.contractUrl(State.url); } else { // Serialised Hash hash = JSON.stringify(State); // Has it been associated with a UID? var uid; if (typeof _History.hashesToUids[hash] !== 'undefined') { uid = _History.hashesToUids[hash]; } else { while (true) { uid = String(Math.floor(Math.random() * 1000)); if (typeof _History.uidsToStates[uid] === 'undefined') { break; } } } // Associate UID with Hash _History.hashesToUids[hash] = uid; _History.uidsToStates[uid] = State; // Simplified Hash hash = History.contractUrl(State.url) + '/uid=' + uid; } } // Return hash return hash; }; /** * _History.uidsToStates * UIDs to States */ _History.uidsToStates = {}; /** * _History.hashesToUids * Serialised States to UIDs */ _History.hashesToUids = {}; /** * _History.getStateByUid(uid) * Get a state by it's UID * @param {string} uid */ _History.getStateByUid = function(uid) { uid = String(uid); var State = _History.uidsToStates[uid] || undefined; return State; }; // ---------------------------------------------------------------------- // State Storage /** * _History.statesByUrl * Store the states indexed by their URLs */ _History.statesByUrl = {}; /** * _History.duplicateStateUrls * Which urls have duplicate states (indexed by url) */ _History.duplicateStateUrls = {}; /** * _History.statesByHash * Store the states indexed by their Hashes */ _History.statesByHash = {}; /** * _History.savedStates * Store the states in an array */ _History.savedStates = []; /** * History.getState() * Get an object containing the data, title and url of the current state * @return {Object} State */ History.getState = function() { return _History.getStateByIndex(); }; /** * History.getStateHash() * Get the hash of the current state * @return {string} hash */ History.getStateHash = function() { return History.contractState(History.getState()); }; /** * _History.getStateByUrl * Get a state by it's url * @param {string} stateUrl */ _History.getStateByUrl = function(stateUrl) { var State = _History.statesByUrl[stateUrl] || undefined; return State; }; /** * _History.getStateByHash * Get a state by it's hash * @param {string} stateHash */ _History.getStateByHash = function(stateHash) { var State = _History.statesByHash[stateHash] || undefined; return State; }; /** * _History.storeState * Store a State * @param {object} State * @return {boolean} true */ _History.storeState = function(newState) { // Prepare var newStateHash = History.contractState(newState), oldState = _History.getStateByUrl(newState.url); // Check for Conflict if (typeof oldState !== 'undefined') { // Compare Hashes var oldStateHash = History.contractState(oldState); if (oldStateHash !== newStateHash) { // We have a conflict _History.duplicateStateUrls[newState.url] = true; } } // Store the State _History.statesByUrl[newState.url] = _History.statesByHash[newStateHash] = newState; // Return true return true; }; /** * _History.isLastState(newState) * Tests to see if the state is the last state * @param {Object} newState * @return {boolean} isLast */ _History.isLastState = function(newState) { // Prepare var newStateHash = History.contractState(newState), oldStateHash = History.getStateHash(); // Check var isLast = _History.savedStates.length && newStateHash === oldStateHash; // Return isLast return isLast; }; /** * _History.saveState * Push a State * @param {Object} newState * @return {boolean} changed */ _History.saveState = function(newState) { // Check Hash if (_History.isLastState(newState)) { return false; } // Push the State _History.savedStates.push(newState); // Return true return true; }; /** * _History.getStateByIndex() * Gets a state by the index * @param {integer} index * @return {Object} */ _History.getStateByIndex = function(index) { // Prepare var State = null; // Handle if (typeof index === 'undefined') { // Get the last inserted State = _History.savedStates[_History.savedStates.length - 1]; } else if (index < 0) { // Get from the end State = _History.savedStates[_History.savedStates.length + index]; } else { // Get from the beginning State = _History.savedStates[index]; } // Return State return State; }; /** * _History.stateUrlExists * Checks if the State Url Exists * @param {string} stateUrl * @return {boolean} exists */ _History.stateUrlExists = function(stateUrl) { // Prepare var exists = typeof _History.statesByUrl[stateUrl] !== 'undefined'; // Return exists return exists; }; /** * _History.urlDuplicateExists * Check if the url has multiple states associated to it * @param {string} stateUrl * @return {boolean} exists */ _History.urlDuplicateExists = function(stateUrl) { var exists = typeof _History.duplicateStateUrls[stateUrl] !== 'undefined'; return exists; }; // ---------------------------------------------------------------------- // Hash Helpers /** * History.getHash() * Gets the current document hash * @return {string} */ History.getHash = function() { var hash = _History.unescapeHash(document.location.hash); return hash; }; /** * _History.unescapeHash() * Normalise and Unescape a Hash * @return {string} */ _History.unescapeHash = function(hash) { var result = _History.normalizeHash(hash); // Unescape hash if (/[\%]/.test(result)) { result = unescape(result); } // Return result return result; }; /** * _History.normalizeHash() * Normalise a hash across browsers * @return {string} */ _History.normalizeHash = function(hash) { var result = hash.replace(/[^#]*#/, '').replace(/#.*/, ''); // Return result return result; }; /** * History.setHash(hash) * Sets the document hash * @param {string} hash * @return {string} */ History.setHash = function(hash, queue) { // Handle Queueing if (queue !== false && History.busy()) { // Wait + Push to Queue History.debug('History.setHash: we must wait', arguments); History.pushQueue({ scope: History, callback: History.setHash, args: arguments, queue: queue }); return false; } // Prepare var adjustedHash = _History.escapeHash(hash); // Log hash History.debug('History.setHash', this, arguments, 'hash:', hash, 'adjustedHash:', adjustedHash, 'oldHash:', document.location.hash); // Make Busy + Continue History.busy(true); // Apply hash document.location.hash = adjustedHash; // Return hash return hash; }; /** * _History.escape() * Normalise and Escape a Hash * @return {string} */ _History.escapeHash = function(hash) { var result = _History.normalizeHash(hash); // Escape hash if (/[^a-zA-Z0-9\/\-\_\%\.]/.test(result)) { result = escape(result); } // Return result return result; }; /** * History.extractHashFromUrl(url) * Extracts the Hash from a URL * @param {string} url * @return {string} url */ History.extractHashFromUrl = function(url) { // Extract the hash var hash = String(url) .replace(/([^#]*)#?([^#]*)#?(.*)/, '$2') ; // Unescape hash hash = _History.unescapeHash(hash); // Return hash return hash; }; /** * History.isTraditionalAnchor(url) * Checks to see if the url is a traditional anchor * @param {string} url * @return {boolean} */ History.isTraditionalAnchor = function(url) { var hash = History.extractHashFromUrl(url), el = document.getElementById(hash), isTraditionalAnchor = typeof el !== 'undefined'; // Return isTraditionalAnchor return isTraditionalAnchor; }; // ---------------------------------------------------------------------- // Queueing /** * History.queues * The list of queues to use * First In, First Out */ History.queues = []; /** * History.busy(value) * @param {boolean} value [optional] * @return {boolean} busy */ History.busy = function(value) { History.debug('History.busy: called: changing [' + (History.busy.flag || false) + '] to [' + (value || false) + ']', History.queues); // Apply if (typeof value !== 'undefined') { History.busy.flag = value; } // Default else if (typeof History.busy.flag === 'undefined') { History.busy.flag = false; } // Queue if (!History.busy.flag) { // Execute the next item in the queue clearTimeout(History.busy.timeout); var fireNext = function() { if (History.busy.flag) return; for (var i = History.queues.length - 1; i >= 0; --i) { var queue = History.queues[i]; if (queue.length === 0) continue; var item = queue.shift(); History.debug('History.busy: firing', item); History.fireQueueItem(item); History.busy.timeout = setTimeout(fireNext, History.options.busyDelay); } }; History.busy.timeout = setTimeout(fireNext, History.options.busyDelay); } // Return return History.busy.flag; }; /** * History.fireQueueItem(item) * Fire a Queue Item * @param {Object} item * @return {Mixed} result */ History.fireQueueItem = function(item) { return item.callback.apply(item.scope || History, item.args || []); }; /** * History.pushQueue(callback,args) * Add an item to the queue * @param {Object} item [scope,callback,args,queue] */ History.pushQueue = function(item) { History.debug('History.pushQueue: called', arguments); // Prepare the queue History.queues[item.queue || 0] = History.queues[item.queue || 0] || []; // Add to the queue History.queues[item.queue || 0].push(item); // End pushQueue closure return true; }; /** * History.queue (item,queue), (func,queue), (func), (item) * Either firs the item now if not busy, or adds it to the queue */ History.queue = function(item, queue) { // Prepare if (typeof item === 'function') { item = { callback: item }; } if (typeof queue !== 'undefined') { item.queue = queue; } // Handle if (History.busy()) { History.pushQueue(item); } else { History.fireQueueItem(item); } // End queue closure return true; }; // ---------------------------------------------------------------------- // State Aliases /** * History.back(queue) * Send the browser history back one item * @param {Integer} queue [optional] */ History.back = function(queue) { History.debug('History.back: called', arguments); // Handle Queueing if (queue !== false && History.busy()) { // Wait + Push to Queue History.debug('History.back: we must wait', arguments); History.pushQueue({ scope: History, callback: History.back, args: arguments, queue: queue }); return false; } // Make Busy + Continue History.busy(true); // Fix a bug in IE6,IE7 if (History.emulated.hashChange && _History.isInternetExplorer()) { // Prepare var currentHash = History.getHash(); // Apply Check setTimeout(function() { var newHash = History.getHash(); if (newHash === currentHash) { // No change occurred, try again History.debug('History.back: trying again'); return History.back(false); } return true; }, History.options.hashChangeCheckerDelay * 5); } // Go back history.go(-1); // End back closure return true; }; /** * History.forward(queue) * Send the browser history forward one item * @param {Integer} queue [optional] */ History.forward = function(queue) { History.debug('History.forward: called', arguments); // Handle Queueing if (queue !== false && History.busy()) { // Wait + Push to Queue History.debug('History.forward: we must wait', arguments); History.pushQueue({ scope: History, callback: History.forward, args: arguments, queue: queue }); return false; } // Make Busy + Continue History.busy(true); // Fix a bug in IE6,IE7 if (History.emulated.hashChange && _History.isInternetExplorer()) { // Prepare var currentHash = History.getHash(); // Apply Check setTimeout(function() { var newHash = History.getHash(); if (newHash === currentHash) { // No change occurred, try again History.debug('History.forward: trying again'); return History.forward(false); } return true; }, History.options.hashChangeCheckerDelay * 5); } // Go forward history.go(1); // End forward closure return true; }; /** * History.go(index,queue) * Send the browser history back or forward index times * @param {Integer} queue [optional] */ History.go = function(index, queue) { History.debug('History.go: called', arguments); // Handle if (index > 0) { // Forward for (var i = 1; i <= index; ++i) { History.forward(queue); } } else if (index < 0) { // Backward for (var i = -1; i >= index; --i) { History.back(queue); } } else { throw new Error('History.go: History.go requires a positive or negative integer passed.'); } // End go closure return true; }; // ---------------------------------------------------------------------- // HTML5 State Support if (!History.emulated.pushState) { /* * Use native HTML5 History API Implementation */ /** * _History.onPopState(event,extra) * Refresh the Current State */ _History.onPopState = function(event) { History.debug('_History.onPopState', this, arguments); // Check for a Hash, and handle apporiatly var currentHash = unescape(History.getHash()); if (currentHash) { // Expand Hash var currentState = History.expandHash(currentHash); if (currentState) { // We were able to parse it, it must be a State! // Let's forward to replaceState History.debug('_History.onPopState: state anchor', currentHash, currentState); History.replaceState(currentState.data, currentState.tite, currentState.url, false); } else { // Traditional Anchor History.debug('_History.onPopState: traditional anchor', currentHash); History.Adapter.trigger(window, 'anchorchange'); History.busy(false); } // We don't care for hashes return false; } // Prepare var currentStateHashExits = null, stateData = {}, stateTitle = null, stateUrl = null, newState = null; // Prepare event = event || {}; if (typeof event.state === 'undefined') { // jQuery if (typeof event.originalEvent !== 'undefined' && typeof event.originalEvent.state !== 'undefined') { event.state = event.originalEvent.state; } // MooTools else if (typeof event.event !== 'undefined' && typeof event.event.state !== 'undefined') { event.state = event.event.state; } } // Fetch Data if (event.state === null) { // Vanilla: State has no data (new state, not pushed) stateData = event.state; } else if (typeof event.state !== 'undefined') { // Vanilla: Back/forward button was used // Using Chrome Fix var newStateUrl = History.expandUrl(document.location.href), oldState = _History.getStateByUrl(newStateUrl), duplicateExists = _History.urlDuplicateExists(newStateUrl); // Does oldState Exist? if (typeof oldState !== 'undefined' && !duplicateExists) { stateData = oldState.data; } else { stateData = event.state; } // Use the way that should work // stateData = event.state; } else { // Vanilla: A new state was pushed, and popstate was called manually // Get State object from the last state var newStateUrl = History.expandUrl(document.location.href), oldState = _History.getStateByUrl(newStateUrl); // Check if the URLs match if (oldState && newStateUrl == oldState.url) { stateData = oldState.data; } else { throw new Error('Unknown state'); } } // Resolve newState stateData = (typeof stateData !== 'object' || stateData === null) ? {} : stateData; stateTitle = stateData.title || '', stateUrl = stateData.url || document.location.href, newState = History.createStateObject(stateData, stateTitle, stateUrl); // Check if we are the same state if (_History.isLastState(newState)) { // There has been no change (just the page's hash has finally propagated) History.debug('_History.onPopState: no change', newState, _History.savedStates); History.busy(false); return false; } // Log History.debug( '_History.onPopState', 'newState:', newState, 'oldState:', _History.getStateByUrl(History.expandUrl(document.location.href)), 'duplicateExists:', _History.urlDuplicateExists(History.expandUrl(document.location.href)) ); // Store the State _History.storeState(newState); _History.saveState(newState); // Force update of the title if (newState.title) { document.title = newState.title } // Fire Our Event History.Adapter.trigger(window, 'statechange'); History.busy(false); // Return true return true; }; History.Adapter.bind(window, 'popstate', _History.onPopState); /** * History.pushState(data,title,url) * Add a new State to the history object, become it, and trigger onpopstate * We have to trigger for HTML4 compatibility * @param {object} data * @param {string} title * @param {string} url * @return {true} */ History.pushState = function(data, title, url, queue) { // Check the State if (History.extractHashFromUrl(url)) { throw new Error('History.js does not support states with fragement-identifiers (hashes/anchors).'); } // Handle Queueing if (queue !== false && History.busy()) { // Wait + Push to Queue History.debug('History.pushState: we must wait', arguments); History.pushQueue({ scope: History, callback: History.pushState, args: arguments, queue: queue }); return false; } // Make Busy + Continue History.busy(true); // Create the newState var newState = History.createStateObject(data, title, url); // Store the newState _History.storeState(newState); // Push the newState history.pushState(newState.data, newState.title, newState.url); // Fire HTML5 Event History.Adapter.trigger(window, 'popstate'); // End pushState closure return true; } /** * History.replaceState(data,title,url) * Replace the State and trigger onpopstate * We have to trigger for HTML4 compatibility * @param {object} data * @param {string} title * @param {string} url * @return {true} */ History.replaceState = function(data, title, url, queue) { // Check the State if (History.extractHashFromUrl(url)) { throw new Error('History.js does not support states with fragement-identifiers (hashes/anchors).'); } // Handle Queueing if (queue !== false && History.busy()) { // Wait + Push to Queue History.debug('History.replaceState: we must wait', arguments); History.pushQueue({ scope: History, callback: History.replaceState, args: arguments, queue: queue }); return false; } // Make Busy + Continue History.busy(true); // Create the newState var newState = History.createStateObject(data, title, url); // Store the newState _History.storeState(newState); // Push the newState history.replaceState(newState.data, newState.title, newState.url); // Fire HTML5 Event History.Adapter.trigger(window, 'popstate'); // End replaceState closure return true; } /** * Ensure Cross Browser Compatibility */ if (navigator.vendor === 'Apple Computer, Inc.') { /** * Fix Safari Initial State Issue */ History.Adapter.onDomLoad(function() { History.debug('Safari Initial State Change Fix'); var currentState = History.createStateObject({}, '', document.location.href); History.pushState(currentState.data, currentState.title, currentState.url); }); /** * Fix Safari HashChange Issue */ History.Adapter.bind(window, 'hashchange', function() { History.Adapter.trigger(window, 'popstate'); }); } } }; // init // Try Load HTML5 Support History.initHtml5(); })(window);