/** * 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 * @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);