(function(context, namespace) { var Hopscotch, HopscotchBubble, HopscotchCalloutManager, HopscotchI18N, customI18N, customRenderer, customEscape, templateToUse = 'bubble_default', Sizzle = window.Sizzle || null, utils, callbacks, helpers, winLoadHandler, defaultOpts, winHopscotch = context[namespace], undefinedStr = 'undefined', waitingToStart = false, // is a tour waiting for the document to finish // loading so that it can start? hasJquery = (typeof window.jQuery !== undefinedStr), hasSessionStorage = false, document = window.document; // If cookies are disabled, accessing sessionStorage can throw an error. try { hasSessionStorage = (typeof window.sessionStorage !== undefinedStr); } catch (err) {} defaultOpts = { smoothScroll: true, scrollDuration: 1000, scrollTopMargin: 200, showCloseButton: true, showPrevButton: false, showNextButton: true, bubbleWidth: 280, bubblePadding: 15, arrowWidth: 20, skipIfNoElement: true, cookieName: 'hopscotch.tour.state', highlight: false, highlightMargin: 0 }; if (winHopscotch) { // Hopscotch already exists. return; } if (!Array.isArray) { Array.isArray = function(obj) { return Object.prototype.toString.call(obj) === '[object Array]'; }; } /** * Called when the page is done loading. * * @private */ winLoadHandler = function() { if (waitingToStart) { winHopscotch.startTour(); } }; /** * utils * ===== * A set of utility functions, mostly for standardizing to manipulate * and extract information from the DOM. Basically these are things I * would normally use jQuery for, but I don't want to require it for * this framework. * * @private */ utils = { /** * addClass * ======== * Adds one or more classes to a DOM element. * * @private */ addClass: function(domEl, classToAdd) { var domClasses, classToAddArr, setClass, i, len; if (!domEl.className) { domEl.className = classToAdd; } else { classToAddArr = classToAdd.split(/\s+/); domClasses = ' ' + domEl.className + ' '; for (i = 0, len = classToAddArr.length; i < len; ++i) { if (domClasses.indexOf(' ' + classToAddArr[i] + ' ') < 0) { domClasses += classToAddArr[i] + ' '; } } domEl.className = domClasses.replace(/^\s+|\s+$/g,''); } }, /** * removeClass * =========== * Remove one or more classes from a DOM element. * * @private */ removeClass: function(domEl, classToRemove) { var domClasses, classToRemoveArr, currClass, i, len; classToRemoveArr = classToRemove.split(/\s+/); domClasses = ' ' + domEl.className + ' '; for (i = 0, len = classToRemoveArr.length; i < len; ++i) { domClasses = domClasses.replace(' ' + classToRemoveArr[i] + ' ', ' '); } domEl.className = domClasses.replace(/^\s+|\s+$/g,''); }, /** * hasClass * ======== * Determine if a given DOM element has a class. */ hasClass: function(domEl, classToCheck){ var classes; if(!domEl.className){ return false; } classes = ' ' + domEl.className + ' '; return (classes.indexOf(' ' + classToCheck + ' ') !== -1); }, /** * @private */ getPixelValue: function(val) { var valType = typeof val; if (valType === 'number') { return val; } if (valType === 'string') { return parseInt(val, 10); } return 0; }, /** * Inspired by Python... returns val if it's defined, otherwise returns the default. * * @private */ valOrDefault: function(val, valDefault) { return typeof val !== undefinedStr ? val : valDefault; }, /** * Invokes a single callback represented by an array. * Example input: ["my_fn", "arg1", 2, "arg3"] * @private */ invokeCallbackArrayHelper: function(arr) { // Logic for a single callback var fn; if (Array.isArray(arr)) { fn = helpers[arr[0]]; if (typeof fn === 'function') { return fn.apply(this, arr.slice(1)); } } }, /** * Invokes one or more callbacks. Array should have at most one level of nesting. * Example input: * ["my_fn", "arg1", 2, "arg3"] * [["my_fn_1", "arg1", "arg2"], ["my_fn_2", "arg2-1", "arg2-2"]] * [["my_fn_1", "arg1", "arg2"], function() { ... }] * @private */ invokeCallbackArray: function(arr) { var i, len; if (Array.isArray(arr)) { if (typeof arr[0] === 'string') { // Assume there are no nested arrays. This is the one and only callback. return utils.invokeCallbackArrayHelper(arr); } else { // assume an array for (i = 0, len = arr.length; i < len; ++i) { utils.invokeCallback(arr[i]); } } } }, /** * Helper function for invoking a callback, whether defined as a function literal * or an array that references a registered helper function. * @private */ invokeCallback: function(cb) { if (typeof cb === 'function') { return cb(); } if (typeof cb === 'string' && helpers[cb]) { // name of a helper return helpers[cb](); } else { // assuming array return utils.invokeCallbackArray(cb); } }, /** * If stepCb (the step-specific helper callback) is passed in, then invoke * it first. Then invoke tour-wide helper. * * @private */ invokeEventCallbacks: function(evtType, stepCb) { var cbArr = callbacks[evtType], callback, fn, i, len; if (stepCb) { return this.invokeCallback(stepCb); } for (i=0, len=cbArr.length; i 0)), showNext: utils.valOrDefault(step.showNextButton, this.opt.showNextButton), showCTA: utils.valOrDefault((step.showCTAButton && step.ctaLabel), false), ctaLabel: step.ctaLabel, showClose: utils.valOrDefault(this.opt.showCloseButton, true) }, step:{ num: idx, isLast: utils.valOrDefault(isLast, false), title: (step.title || ''), content: (step.content || ''), placement: step.placement, padding: utils.valOrDefault(step.padding, this.opt.bubblePadding), width: utils.getPixelValue(step.width) || this.opt.bubbleWidth, customData: (step.customData || {}) }, tour:{ isTour: this.opt.isTourBubble, numSteps: totalSteps, unsafe: utils.valOrDefault(unsafe, false), customData: (customTourData || {}) } }; // Render the bubble's content. // Use tour renderer if available, then the global customRenderer if defined. if(typeof tourSpecificRenderer === 'function'){ el.innerHTML = tourSpecificRenderer(opts); } else if(typeof tourSpecificRenderer === 'string'){ if(!hopscotch.templates || (typeof hopscotch.templates[tourSpecificRenderer] !== 'function')){ throw 'Bubble rendering failed - template "' + tourSpecificRenderer + '" is not a function.'; } el.innerHTML = hopscotch.templates[tourSpecificRenderer](opts); } else if(customRenderer){ el.innerHTML = customRenderer(opts); } else{ if(!hopscotch.templates || (typeof hopscotch.templates[templateToUse] !== 'function')){ throw 'Bubble rendering failed - template "' + templateToUse + '" is not a function.'; } el.innerHTML = hopscotch.templates[templateToUse](opts); } // Find arrow among new child elements. children = el.children; numChildren = children.length; for (i = 0; i < numChildren; i++){ node = children[i]; if(utils.hasClass(node, 'hopscotch-arrow')){ this.arrowEl = node; } } // Set z-index and arrow placement el.style.zIndex = step.zindex || ''; this._setArrow(step.placement); // Set bubble positioning // Make sure we're using visibility:hidden instead of display:none for height/width calculations. this.hide(false); this.setPosition(step); // only want to adjust window scroll for non-fixed elements if (callback) { callback(!step.fixedElement); } return this; }, /** * Get the I18N step number for the current step. * * @private */ _getStepI18nNum: function(idx) { var stepNumI18N = utils.getI18NString('stepNums'); if (stepNumI18N && idx < stepNumI18N.length) { idx = stepNumI18N[idx]; } else { idx = idx + 1; } return idx; }, /** * Sets which side the arrow is on. * * @private */ _setArrow: function(orientation) { utils.removeClass(this.arrowEl, 'down up right left'); // Whatever the orientation is, we want to arrow to appear // "opposite" of the orientation. E.g., a top orientation // requires a bottom arrow. if (orientation === 'top') { utils.addClass(this.arrowEl, 'down'); } else if (orientation === 'bottom') { utils.addClass(this.arrowEl, 'up'); } else if (orientation === 'left') { utils.addClass(this.arrowEl, 'right'); } else if (orientation === 'right') { utils.addClass(this.arrowEl, 'left'); } }, /** * @private */ _getArrowDirection: function() { if (this.placement === 'top') { return 'down'; } if (this.placement === 'bottom') { return 'up'; } if (this.placement === 'left') { return 'right'; } if (this.placement === 'right') { return 'left'; } }, show: function() { var self = this, fadeClass = 'fade-in-' + this._getArrowDirection(), fadeDur = 1000; utils.removeClass(this.element, 'hide'); utils.addClass(this.element, fadeClass); setTimeout(function() { utils.removeClass(self.element, 'invisible'); }, 50); setTimeout(function() { utils.removeClass(self.element, fadeClass); }, fadeDur); this.isShowing = true; this.highlight.show(); return this; }, hide: function(remove) { var el = this.element; remove = utils.valOrDefault(remove, true); el.style.top = ''; el.style.left = ''; // display: none if (remove) { utils.addClass(el, 'hide'); utils.removeClass(el, 'invisible'); } // opacity: 0 else { utils.removeClass(el, 'hide'); utils.addClass(el, 'invisible'); } utils.removeClass(el, 'animate fade-in-up fade-in-down fade-in-right fade-in-left'); this.isShowing = false; this.highlight.hide(); return this; }, destroy: function() { var el = this.element; if (el) { el.parentNode.removeChild(el); } utils.removeEvtListener(el, 'click', this.clickCb); }, _handleBubbleClick: function(evt){ var action; //Recursively look up the parent tree until we find a match //with one of the classes we're looking for, or the triggering element. function findMatchRecur(el){ /* We're going to make the assumption that we're not binding * multiple event classes to the same element. * (next + previous = wait... err... what?) * * In the odd event we end up with an element with multiple * possible matches, the following priority order is applied: * hopscotch-cta, hopscotch-next, hopscotch-prev, hopscotch-close */ if(el === evt.currentTarget){ return null; } if(utils.hasClass(el, 'hopscotch-cta')){ return 'cta'; } if(utils.hasClass(el, 'hopscotch-next')){ return 'next'; } if(utils.hasClass(el, 'hopscotch-prev')){ return 'prev'; } if(utils.hasClass(el, 'hopscotch-close')){ return 'close'; } /*else*/ return findMatchRecur(el.parentElement); } action = findMatchRecur(evt.target); //Now that we know what action we should take, let's take it. if (action === 'cta'){ if (!this.opt.isTourBubble) { // This is a callout. Close the callout when CTA is clicked. winHopscotch.getCalloutManager().removeCallout(this.currStep.id); } // Call onCTA callback if one is provided if (this.currStep.onCTA) { utils.invokeCallback(this.currStep.onCTA); } } else if (action === 'next'){ winHopscotch.nextStep(true); } else if (action === 'prev'){ winHopscotch.prevStep(true); } else if (action === 'close'){ if (this.opt.isTourBubble){ var currStepNum = winHopscotch.getCurrStepNum(), currTour = winHopscotch.getCurrTour(), doEndCallback = (currStepNum === currTour.steps.length-1); utils.invokeEventCallbacks('close'); winHopscotch.endTour(true, doEndCallback); } else { if (this.opt.onClose) { utils.invokeCallback(this.opt.onClose); } if (this.opt.id && !this.opt.isTourBubble) { // Remove via the HopscotchCalloutManager. // removeCallout() calls HopscotchBubble.destroy internally. winHopscotch.getCalloutManager().removeCallout(this.opt.id); } else { this.destroy(); } } utils.evtPreventDefault(evt); } //Otherwise, do nothing. We didn't click on anything relevant. }, init: function(initOpt) { var el = document.createElement('div'), self = this, resizeCooldown = false, // for updating after window resize onWinResize, appendToBody, children, numChildren, node, i, opt; //Register DOM element for this bubble. this.element = el; //Merge bubble options with defaults. opt = { showPrevButton: defaultOpts.showPrevButton, showNextButton: defaultOpts.showNextButton, bubbleWidth: defaultOpts.bubbleWidth, bubblePadding: defaultOpts.bubblePadding, arrowWidth: defaultOpts.arrowWidth, showNumber: true, isTourBubble: true }; initOpt = (typeof initOpt === undefinedStr ? {} : initOpt); utils.extend(opt, initOpt); this.opt = opt; //Apply classes to bubble. Add "animated" for fade css animation el.className = 'hopscotch-bubble animated'; if (!opt.isTourBubble) { utils.addClass(el, 'hopscotch-callout no-number'); } self.highlight = new HopscotchHighlight(initOpt); /** * Not pretty, but IE8 doesn't support Function.bind(), so I'm * relying on closures to keep a handle of "this". * Reset position of bubble when window is resized * * @private */ onWinResize = function() { if (resizeCooldown || !self.isShowing) { return; } resizeCooldown = true; setTimeout(function() { self.setPosition(self.currStep); resizeCooldown = false; }, 100); }; //Add listener to reset bubble position on window resize utils.addEvtListener(window, 'resize', onWinResize); //Create our click callback handler and keep a //reference to it for later. this.clickCb = function(evt){ self._handleBubbleClick(evt); }; utils.addEvtListener(el, 'click', this.clickCb); //Hide the bubble by default this.hide(); //Finally, append our new bubble to body once the DOM is ready. if (utils.documentIsReady()) { document.body.appendChild(el); self.highlight.addToDom(); } else { // Moz, webkit, Opera if (document.addEventListener) { appendToBody = function() { document.removeEventListener('DOMContentLoaded', appendToBody); window.removeEventListener('load', appendToBody); document.body.appendChild(el); self.highlight.addToDom(); }; document.addEventListener('DOMContentLoaded', appendToBody, false); } // IE else { appendToBody = function() { if (document.readyState === 'complete') { document.detachEvent('onreadystatechange', appendToBody); window.detachEvent('onload', appendToBody); document.body.appendChild(el); self.highlight.addToDom(); } }; document.attachEvent('onreadystatechange', appendToBody); } utils.addEvtListener(window, 'load', appendToBody); } } }; /** * HopscotchCalloutManager * * @class Manages the creation and destruction of single callouts. * @constructor */ HopscotchCalloutManager = function() { var callouts = {}; /** * createCallout * * Creates a standalone callout. This callout has the same API * as a Hopscotch tour bubble. * * @param {Object} opt The options for the callout. For the most * part, these are the same options as you would find in a tour * step. */ this.createCallout = function(opt) { var callout; if (opt.id) { if (callouts[opt.id]) { throw 'Callout by that id already exists. Please choose a unique id.'; } opt.showNextButton = opt.showPrevButton = false; opt.isTourBubble = false; callout = new HopscotchBubble(opt); callouts[opt.id] = callout; if (opt.target) { callout.render(opt, null, function() { callout.show(); }); } } else { throw 'Must specify a callout id.'; } return callout; }; /** * getCallout * * Returns a callout by its id. * * @param {String} id The id of the callout to fetch. * @returns {Object} HopscotchBubble */ this.getCallout = function(id) { return callouts[id]; }; /** * removeAllCallouts * * Removes all existing callouts. */ this.removeAllCallouts = function() { var calloutId, callout; for (calloutId in callouts) { if (callouts.hasOwnProperty(calloutId)) { this.removeCallout(calloutId); } } }; /** * removeAllCallout * * Removes an existing callout by id. * * @param {String} id The id of the callout to remove. */ this.removeCallout = function(id) { var callout = callouts[id]; callouts[id] = null; if (!callout) { return; } callout.destroy(); }; }; /** * Hopscotch * * @class Creates the Hopscotch object. Used to manage tour progress and configurations. * @constructor * @param {Object} initOptions Options to be passed to `configure()`. */ Hopscotch = function(initOptions) { var self = this, // for targetClickNextFn bubble, calloutMgr, opt, currTour, currStepNum, cookieTourId, cookieTourStep, _configure, /** * getBubble * * Singleton accessor function for retrieving or creating bubble object. * * @private * @param setOptions {Boolean} when true, transfers configuration options to the bubble * @returns {Object} HopscotchBubble */ getBubble = function(setOptions) { if (!bubble) { bubble = new HopscotchBubble(opt); } if (setOptions) { utils.extend(bubble.opt, { bubblePadding: getOption('bubblePadding'), bubbleWidth: getOption('bubbleWidth'), showNextButton: getOption('showNextButton'), showPrevButton: getOption('showPrevButton'), showCloseButton: getOption('showCloseButton'), arrowWidth: getOption('arrowWidth') }); } return bubble; }, /** * Convenience method for getting an option. Returns custom config option * or the default config option if no custom value exists. * * @private * @param name {String} config option name * @returns {Object} config option value */ getOption = function(name) { if (typeof opt === 'undefined') { return defaultOpts[name]; } return utils.valOrDefault(opt[name], defaultOpts[name]); }, /** * getCurrStep * * @private * @returns {Object} the step object corresponding to the current value of currStepNum */ getCurrStep = function() { var step; if (currStepNum < 0 || currStepNum >= currTour.steps.length) { step = null; } else { step = currTour.steps[currStepNum]; } return step; }, /** * Used for nextOnTargetClick * * @private */ targetClickNextFn = function() { self.nextStep(); }, /** * adjustWindowScroll * * Checks if the bubble or target element is partially or completely * outside of the viewport. If it is, adjust the window scroll position * to bring it back into the viewport. * * @private * @param {Function} cb Callback to invoke after done scrolling. */ adjustWindowScroll = function(cb) { var bubble = getBubble(), // Calculate the bubble element top and bottom position bubbleEl = bubble.element, bubbleTop = utils.getPixelValue(bubbleEl.style.top), bubbleBottom = bubbleTop + utils.getPixelValue(bubbleEl.offsetHeight), // Calculate the target element top and bottom position targetEl = utils.getStepTarget(getCurrStep()), targetBounds = targetEl.getBoundingClientRect(), targetElTop = targetBounds.top + utils.getScrollTop(), targetElBottom = targetBounds.bottom + utils.getScrollTop(), // The higher of the two: bubble or target targetTop = (bubbleTop < targetElTop) ? bubbleTop : targetElTop, // The lower of the two: bubble or target targetBottom = (bubbleBottom > targetElBottom) ? bubbleBottom : targetElBottom, // Calculate the current viewport top and bottom windowTop = utils.getScrollTop(), windowBottom = windowTop + utils.getWindowHeight(), // This is our final target scroll value. scrollToVal = targetTop - getOption('scrollTopMargin'), scrollEl, yuiAnim, yuiEase, direction, scrollIncr, scrollTimeout, scrollTimeoutFn; // Target and bubble are both visible in viewport if (targetTop >= windowTop && (targetTop <= windowTop + getOption('scrollTopMargin') || targetBottom <= windowBottom)) { if (cb) { cb(); } // HopscotchBubble.show } // Abrupt scroll to scroll target else if (!getOption('smoothScroll')) { window.scrollTo(0, scrollToVal); if (cb) { cb(); } // HopscotchBubble.show } // Smooth scroll to scroll target else { // Use YUI if it exists if (typeof YAHOO !== undefinedStr && typeof YAHOO.env !== undefinedStr && typeof YAHOO.env.ua !== undefinedStr && typeof YAHOO.util !== undefinedStr && typeof YAHOO.util.Scroll !== undefinedStr) { scrollEl = YAHOO.env.ua.webkit ? document.body : document.documentElement; yuiEase = YAHOO.util.Easing ? YAHOO.util.Easing.easeOut : undefined; yuiAnim = new YAHOO.util.Scroll(scrollEl, { scroll: { to: [0, scrollToVal] } }, getOption('scrollDuration')/1000, yuiEase); yuiAnim.onComplete.subscribe(cb); yuiAnim.animate(); } // Use jQuery if it exists else if (hasJquery) { jQuery('body, html').animate({ scrollTop: scrollToVal }, getOption('scrollDuration'), cb); } // Use my crummy setInterval scroll solution if we're using plain, vanilla Javascript. else { if (scrollToVal < 0) { scrollToVal = 0; } // 48 * 10 == 480ms scroll duration // make it slightly less than CSS transition duration because of // setInterval overhead. // To increase or decrease duration, change the divisor of scrollIncr. direction = (windowTop > targetTop) ? -1 : 1; // -1 means scrolling up, 1 means down scrollIncr = Math.abs(windowTop - scrollToVal) / (getOption('scrollDuration')/10); scrollTimeoutFn = function() { var scrollTop = utils.getScrollTop(), scrollTarget = scrollTop + (direction * scrollIncr); if ((direction > 0 && scrollTarget >= scrollToVal) || (direction < 0 && scrollTarget <= scrollToVal)) { // Overshot our target. Just manually set to equal the target // and clear the interval scrollTarget = scrollToVal; if (cb) { cb(); } // HopscotchBubble.show window.scrollTo(0, scrollTarget); return; } window.scrollTo(0, scrollTarget); if (utils.getScrollTop() === scrollTop) { // Couldn't scroll any further. if (cb) { cb(); } // HopscotchBubble.show return; } // If we reached this point, that means there's still more to scroll. setTimeout(scrollTimeoutFn, 10); }; scrollTimeoutFn(); } } }, /** * goToStepWithTarget * * Helper function to increment the step number until a step is found where * the step target exists or until we reach the end/beginning of the tour. * * @private * @param {Number} direction Either 1 for incrementing or -1 for decrementing * @param {Function} cb The callback function to be invoked when the step has been found */ goToStepWithTarget = function(direction, cb) { var target, step, goToStepFn; if (currStepNum + direction >= 0 && currStepNum + direction < currTour.steps.length) { currStepNum += direction; step = getCurrStep(); goToStepFn = function() { target = utils.getStepTarget(step); if (target) { // We're done! Return the step number via the callback. cb(currStepNum); } else { // Haven't found a valid target yet. Recursively call // goToStepWithTarget. utils.invokeEventCallbacks('error'); goToStepWithTarget(direction, cb); } }; if (step.delay) { setTimeout(goToStepFn, step.delay); } else { goToStepFn(); } } else { cb(-1); // signal that we didn't find any step with a valid target } }, /** * changeStep * * Helper function to change step by going forwards or backwards 1. * nextStep and prevStep are publicly accessible wrappers for this function. * * @private * @param {Boolean} doCallbacks Flag for invoking onNext or onPrev callbacks * @param {Number} direction Either 1 for "next" or -1 for "prev" */ changeStep = function(doCallbacks, direction) { var bubble = getBubble(), self = this, step, origStep, wasMultiPage, changeStepCb; bubble.hide(); doCallbacks = utils.valOrDefault(doCallbacks, true); step = getCurrStep(); origStep = step; if (direction > 0) { wasMultiPage = origStep.multipage; } else { wasMultiPage = (currStepNum > 0 && currTour.steps[currStepNum-1].multipage); } /** * Callback for goToStepWithTarget * * @private */ changeStepCb = function(stepNum) { var doShowFollowingStep; if (stepNum === -1) { // Wasn't able to find a step with an existing element. End tour. return this.endTour(true); } if (doCallbacks) { if (direction > 0) { doShowFollowingStep = utils.invokeEventCallbacks('next', origStep.onNext); } else { doShowFollowingStep = utils.invokeEventCallbacks('prev', origStep.onPrev); } } // If the state of the tour is updated in a callback, assume the client // doesn't want to go to next step since they specifically updated. if (stepNum !== currStepNum) { return; } if (wasMultiPage) { // Update state for the next page utils.setState(getOption('cookieName'), currTour.id + ':' + currStepNum, 1); // Next step is on a different page, so no need to attempt to render it. return; } doShowFollowingStep = utils.valOrDefault(doShowFollowingStep, true); // If the onNext/onPrev callback returned false, halt the tour and // don't show the next step. if (doShowFollowingStep) { this.showStep(stepNum); } else { // Halt tour (but don't clear state) this.endTour(false); } }; if (!wasMultiPage && getOption('skipIfNoElement')) { goToStepWithTarget(direction, function(stepNum) { changeStepCb.call(self, stepNum); }); } else if (currStepNum + direction >= 0 && currStepNum + direction < currTour.steps.length) { // only try incrementing once, and invoke error callback if no target is found currStepNum += direction; step = getCurrStep(); if (!utils.getStepTarget(step) && !wasMultiPage) { utils.invokeEventCallbacks('error'); return this.endTour(true, false); } changeStepCb.call(this, currStepNum); } return this; }, /** * loadTour * * Loads, but does not display, tour. * * @private * @param tour The tour JSON object */ loadTour = function(tour) { var tmpOpt = {}, prop, tourState, tourPair; // Set tour-specific configurations for (prop in tour) { if (tour.hasOwnProperty(prop) && prop !== 'id' && prop !== 'steps') { tmpOpt[prop] = tour[prop]; } } //this.resetDefaultOptions(); // reset all options so there are no surprises // TODO check number of config properties of tour _configure.call(this, tmpOpt, true); // Get existing tour state, if it exists. tourState = utils.getState(getOption('cookieName')); if (tourState) { tourPair = tourState.split(':'); cookieTourId = tourPair[0]; // selecting tour is not supported by this framework. cookieTourStep = tourPair[1]; cookieTourStep = parseInt(cookieTourStep, 10); } return this; }, /** * Find the first step to show for a tour. (What is the first step with a * target on the page?) */ findStartingStep = function(startStepNum, cb) { var step, target, stepNum; currStepNum = startStepNum || 0; step = getCurrStep(); target = utils.getStepTarget(step); if (target) { // First step had an existing target. cb(currStepNum); return; } if (!target) { // Previous target doesn't exist either. The user may have just // clicked on a link that wasn't part of the tour. Another possibility is that // the user clicked on the correct link, but the target is just missing for // whatever reason. In either case, we should just advance until we find a step // that has a target on the page or end the tour if we can't find such a step. utils.invokeEventCallbacks('error'); if (getOption('skipIfNoElement')) { goToStepWithTarget(1, cb); return; } else { currStepNum = -1; cb(currStepNum); } } }, showStepHelper = function(stepNum) { var step = currTour.steps[stepNum], tourSteps = currTour.steps, numTourSteps = tourSteps.length, cookieVal = currTour.id + ':' + stepNum, bubble = getBubble(), targetEl = utils.getStepTarget(step), isLast, showBubble; showBubble = function() { bubble.show(); utils.invokeEventCallbacks('show', step.onShow); }; // Update bubble for current step currStepNum = stepNum; bubble.hide(false); isLast = (stepNum === numTourSteps - 1); bubble.render(step, stepNum, function(adjustScroll) { // when done adjusting window scroll, call showBubble helper fn if (adjustScroll) { adjustWindowScroll(showBubble); } else { showBubble(); } // If we want to advance to next step when user clicks on target. if (step.nextOnTargetClick) { utils.addEvtListener(targetEl, 'click', targetClickNextFn); } }); utils.setState(getOption('cookieName'), cookieVal, 1); }, /** * init * * Initializes the Hopscotch object. * * @private */ init = function(initOptions) { if (initOptions) { //initOptions.cookieName = initOptions.cookieName || 'hopscotch.tour.state'; this.configure(initOptions); } }; /** * getCalloutManager * * Gets the callout manager. * * @returns {Object} HopscotchCalloutManager * */ this.getCalloutManager = function() { if (typeof calloutMgr === undefinedStr) { calloutMgr = new HopscotchCalloutManager(); } return calloutMgr; }; /** * startTour * * Begins the tour. * * @param {Object} tour The tour JSON object * @stepNum {Number} stepNum __Optional__ The step number to start from * @returns {Object} Hopscotch * */ this.startTour = function(tour, stepNum) { var bubble, currStepNum, self = this; // loadTour if we are calling startTour directly. (When we call startTour // from window onLoad handler, we'll use currTour) if (!currTour) { currTour = tour; loadTour.call(this, tour); } if (typeof stepNum !== undefinedStr) { if (stepNum >= currTour.steps.length) { throw 'Specified step number out of bounds.'; } currStepNum = stepNum; } // If document isn't ready, wait for it to finish loading. // (so that we can calculate positioning accurately) if (!utils.documentIsReady()) { waitingToStart = true; return this; } if (typeof currStepNum === "undefined" && currTour.id === cookieTourId && typeof cookieTourStep !== undefinedStr) { currStepNum = cookieTourStep; } else if (!currStepNum) { currStepNum = 0; } // Find the current step we should begin the tour on, and then actually start the tour. findStartingStep(currStepNum, function(stepNum) { var target = (stepNum !== -1) && utils.getStepTarget(currTour.steps[stepNum]); if (!target) { // Should we trigger onEnd callback? Let's err on the side of caution // and not trigger it. Don't want weird stuff happening on a page that // wasn't meant for the tour. Up to the developer to fix their tour. self.endTour(false, false); return; } utils.invokeEventCallbacks('start'); bubble = getBubble(); // TODO: do we still need this call to .hide()? No longer using opt.animate... // Leaving it in for now to play it safe bubble.hide(false); // make invisible for boundingRect calculations when opt.animate == true self.isActive = true; if (!utils.getStepTarget(getCurrStep())) { // First step element doesn't exist utils.invokeEventCallbacks('error'); if (getOption('skipIfNoElement')) { self.nextStep(false); } } else { self.showStep(stepNum); } }); return this; }; /** * showStep * * Skips to a specific step and renders the corresponding bubble. * * @stepNum {Number} stepNum The step number to show * @returns {Object} Hopscotch */ this.showStep = function(stepNum) { var step = currTour.steps[stepNum]; if (step.delay) { setTimeout(function() { showStepHelper(stepNum); }, step.delay); } else { showStepHelper(stepNum); } return this; }; /** * prevStep * * Jump to the previous step. * * @param {Boolean} doCallbacks Flag for invoking onPrev callback. Defaults to true. * @returns {Object} Hopscotch */ this.prevStep = function(doCallbacks) { changeStep.call(this, doCallbacks, -1); return this; }; /** * nextStep * * Jump to the next step. * * @param {Boolean} doCallbacks Flag for invoking onNext callback. Defaults to true. * @returns {Object} Hopscotch */ this.nextStep = function(doCallbacks) { var step = getCurrStep(), targetEl = utils.getStepTarget(step); if (step.nextOnTargetClick) { // Detach the listener after we've clicked on the target OR the next button. utils.removeEvtListener(targetEl, 'click', targetClickNextFn); } changeStep.call(this, doCallbacks, 1); return this; }; /** * endTour * * Cancels out of an active tour. * * @param {Boolean} clearState Flag for clearing state. Defaults to true. * @param {Boolean} doCallbacks Flag for invoking 'onEnd' callbacks. Defaults to true. * @returns {Object} Hopscotch */ this.endTour = function(clearState, doCallbacks) { var bubble = getBubble(); clearState = utils.valOrDefault(clearState, true); doCallbacks = utils.valOrDefault(doCallbacks, true); currStepNum = 0; cookieTourStep = undefined; bubble.hide(); if (clearState) { utils.clearState(getOption('cookieName')); } if (this.isActive) { this.isActive = false; if (currTour && doCallbacks) { utils.invokeEventCallbacks('end'); } } this.removeCallbacks(null, true); this.resetDefaultOptions(); currTour = null; return this; }; /** * getCurrTour * * @return {Object} The currently loaded tour. */ this.getCurrTour = function() { return currTour; }; /** * getCurrTarget * * @return {Object} The currently visible target. */ this.getCurrTarget = function() { return utils.getStepTarget(getCurrStep()); }; /** * getCurrStepNum * * @return {number} The current zero-based step number. */ this.getCurrStepNum = function() { return currStepNum; }; /** * refreshBubblePosition * * Tell hopscotch that the position of the current tour element changed * and the bubble therefore needs to be redrawn * * @returns {Object} Hopscotch */ this.refreshBubblePosition = function() { bubble.setPosition(getCurrStep()); return this; }; /** * listen * * Adds a callback for one of the event types. Valid event types are: * * @param {string} evtType "start", "end", "next", "prev", "show", "close", or "error" * @param {Function} cb The callback to add. * @param {Boolean} isTourCb Flag indicating callback is from a tour definition. * For internal use only! * @returns {Object} Hopscotch */ this.listen = function(evtType, cb, isTourCb) { if (evtType) { callbacks[evtType].push({ cb: cb, fromTour: isTourCb }); } return this; }; /** * unlisten * * Removes a callback for one of the event types, e.g. 'start', 'next', etc. * * @param {string} evtType "start", "end", "next", "prev", "show", "close", or "error" * @param {Function} cb The callback to remove. * @returns {Object} Hopscotch */ this.unlisten = function(evtType, cb) { var evtCallbacks = callbacks[evtType], i, len; for (i = 0, len = evtCallbacks.length; i < len; ++i) { if (evtCallbacks[i] === cb) { evtCallbacks.splice(i, 1); } } return this; }; /** * removeCallbacks * * Remove callbacks for hopscotch events. If tourOnly is set to true, only * removes callbacks specified by a tour (callbacks set by external calls * to hopscotch.configure or hopscotch.listen will not be removed). If * evtName is null or undefined, callbacks for all events will be removed. * * @param {string} evtName Optional Event name for which we should remove callbacks * @param {boolean} tourOnly Optional flag to indicate we should only remove callbacks added * by a tour. Defaults to false. * @returns {Object} Hopscotch */ this.removeCallbacks = function(evtName, tourOnly) { var cbArr, i, len, evt; // If evtName is null or undefined, remove callbacks for all events. for (evt in callbacks) { if (!evtName || evtName === evt) { if (tourOnly) { cbArr = callbacks[evt]; for (i=0, len=cbArr.length; i < len; ++i) { if (cbArr[i].fromTour) { cbArr.splice(i--, 1); --len; } } } else { callbacks[evt] = []; } } } return this; }; /** * registerHelper * ============== * Registers a helper function to be used as a callback function. * * @param {String} id The id of the function. * @param {Function} id The callback function. */ this.registerHelper = function(id, fn) { if (typeof id === 'string' && typeof fn === 'function') { helpers[id] = fn; } }; this.unregisterHelper = function(id) { helpers[id] = null; }; this.invokeHelper = function(id) { var args = [], i, len; for (i = 1, len = arguments.length; i < len; ++i) { args.push(arguments[i]); } if (helpers[id]) { helpers[id].call(null, args); } }; /** * setCookieName * * Sets the cookie name (or sessionStorage name, if supported) used for multi-page * tour persistence. * * @param {String} name The cookie name * @returns {Object} Hopscotch */ this.setCookieName = function(name) { opt.cookieName = name; return this; }; /** * resetDefaultOptions * * Resets all configuration options to default. * * @returns {Object} Hopscotch */ this.resetDefaultOptions = function() { opt = {}; return this; }; /** * resetDefaultI18N * * Resets all i18n. * * @returns {Object} Hopscotch */ this.resetDefaultI18N = function() { customI18N = {}; return this; }; /** * hasState * * Returns state from a previous tour run, if it exists. * * @returns {String} State of previous tour run, or empty string if none exists. */ this.getState = function() { return utils.getState(getOption('cookieName')); }; /** * _configure * * @see this.configure * @private * @param options * @param {Boolean} isTourOptions Should be set to true when setting options from a tour definition. */ _configure = function(options, isTourOptions) { var bubble, events = ['next', 'prev', 'start', 'end', 'show', 'error', 'close'], eventPropName, callbackProp, i, len; if (!opt) { this.resetDefaultOptions(); } utils.extend(opt, options); if (options) { utils.extend(customI18N, options.i18n); } for (i = 0, len = events.length; i < len; ++i) { // At this point, options[eventPropName] may have changed from an array // to a function. eventPropName = 'on' + events[i].charAt(0).toUpperCase() + events[i].substring(1); if (options[eventPropName]) { this.listen(events[i], options[eventPropName], isTourOptions); } } bubble = getBubble(true); return this; }; /** * configure * *
     * VALID OPTIONS INCLUDE...
     *
     * - bubbleWidth:     Number   - Default bubble width. Defaults to 280.
     * - bubblePadding:   Number   - DEPRECATED. Default bubble padding. Defaults to 15.
     * - smoothScroll:    Boolean  - should the page scroll smoothly to the next
     *                               step? Defaults to TRUE.
     * - scrollDuration:  Number   - Duration of page scroll. Only relevant when
     *                               smoothScroll is set to true. Defaults to
     *                               1000ms.
     * - scrollTopMargin: NUMBER   - When the page scrolls, how much space should there
     *                               be between the bubble/targetElement and the top
     *                               of the viewport? Defaults to 200.
     * - showCloseButton: Boolean  - should the tour bubble show a close (X) button?
     *                               Defaults to TRUE.
     * - showPrevButton:  Boolean  - should the bubble have the Previous button?
     *                               Defaults to FALSE.
     * - showNextButton:  Boolean  - should the bubble have the Next button?
     *                               Defaults to TRUE.
     * - arrowWidth:      Number   - Default arrow width. (space between the bubble
     *                               and the targetEl) Used for bubble position
     *                               calculation. Only use this option if you are
     *                               using your own custom CSS. Defaults to 20.
     * - skipIfNoElement  Boolean  - If a specified target element is not found,
     *                               should we skip to the next step? Defaults to
     *                               TRUE.
     * - onNext:          Function - A callback to be invoked after every click on
     *                               a "Next" button.
     *
     * - i18n:            Object   - For i18n purposes. Allows you to change the
     *                               text of button labels and step numbers.
     * - i18n.stepNums:   Array\ - Provide a list of strings to be shown as
     *                               the step number, based on index of array. Unicode
     *                               characters are supported. (e.g., ['一',
     *                               '二', '三']) If there are more steps
     *                               than provided numbers, Arabic numerals
     *                               ('4', '5', '6', etc.) will be used as default.
     * - highlight:       Boolean  - Shows an overlay that highlights the selected element
     *                               Defaults to FALSE.
     * - highlightMargin: Number   - Amount of margin around the selected element to show
     *                               Defaults to 0
     *
     * // =========
     * // CALLBACKS
     * // =========
     * - onNext:          Function - Invoked after every click on a "Next" button.
     * - onPrev:          Function - Invoked after every click on a "Prev" button.
     * - onStart:         Function - Invoked when the tour is started.
     * - onEnd:           Function - Invoked when the tour ends.
     * - onClose:         Function - Invoked when the user closes the tour before finishing.
     * - onError:         Function - Invoked when the specified target element doesn't exist on the page.
     *
     * // ====
     * // I18N
     * // ====
     * i18n:              OBJECT      - For i18n purposes. Allows you to change the text
     *                                  of button labels and step numbers.
     * i18n.nextBtn:      STRING      - Label for next button
     * i18n.prevBtn:      STRING      - Label for prev button
     * i18n.doneBtn:      STRING      - Label for done button
     * i18n.skipBtn:      STRING      - Label for skip button
     * i18n.closeTooltip: STRING      - Text for close button tooltip
     * i18n.stepNums:   ARRAY - Provide a list of strings to be shown as
     *                                  the step number, based on index of array. Unicode
     *                                  characters are supported. (e.g., ['一',
     *                                  '二', '三']) If there are more steps
     *                                  than provided numbers, Arabic numerals
     *                                  ('4', '5', '6', etc.) will be used as default.
     * 
* * @example hopscotch.configure({ scrollDuration: 1000, scrollTopMargin: 150 }); * @example * hopscotch.configure({ * scrollTopMargin: 150, * onStart: function() { * alert("Have fun!"); * }, * i18n: { * nextBtn: 'Forward', * prevBtn: 'Previous' * closeTooltip: 'Quit' * } * }); * * @param {Object} options A hash of configuration options. * @returns {Object} Hopscotch */ this.configure = function(options) { return _configure.call(this, options, false); }; /** * Set the template that should be used for rendering Hopscotch bubbles. * If a string, it's assumed your template is available in the * hopscotch.templates namespace. * * @param {String|Function(obj)} The template to use for rendering. * @returns {Object} The Hopscotch object (for chaining). */ this.setRenderer = function(render){ var typeOfRender = typeof render; if(typeOfRender === 'string'){ templateToUse = render; customRenderer = undefined; } else if(typeOfRender === 'function'){ customRenderer = render; } return this; }; /** * Sets the escaping method to be used by JST templates. * * @param {Function} - The escape method to use. * @returns {Object} The Hopscotch object (for chaining). */ this.setEscaper = function(esc){ if (typeof esc === 'function'){ customEscape = esc; } return this; }; init.call(this, initOptions); }; HopscotchHighlight = function(opt) { this.init(opt); }; HopscotchHighlight.prototype = { init: function(initOpt) { var opt; var el = { top: document.createElement('div'), left: document.createElement('div'), right: document.createElement('div'), bottom: document.createElement('div') }; this.element = el; //Merge highlight options with defaults. opt = { highlight: defaultOpts.highlight, highlightMargin: defaultOpts.highlightMargin }; initOpt = (typeof initOpt === undefinedStr ? {} : initOpt); utils.extend(opt, initOpt); this.opt = opt; for (var e in this.element){ utils.addClass(this.element[e], 'hopscotch-overlay'); } }, addToDom: function(){ for (var e in this.element){ document.body.appendChild(this.element[e]); } }, show: function(){ // check if step has disabled the highlight: if (!this.stepOpts.highlight){ return; } for (var e in this.element){ utils.removeClass(this.element[e], 'hide'); } }, hide: function(){ for (var e in this.element){ utils.addClass(this.element[e], 'hide'); } }, setPosition: function(step, targetBounds){ // check if step has disabled the highlight: if (!this.stepOpts.highlight){ return; } var margin = this.stepOpts.highlightMargin; var body = document.body, html = document.documentElement; var documentHeight = Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight ); // top div: el = this.element.top; el.style.top = '0px'; el.style.left = '0px'; el.style.width = window.screen.width + 'px'; el.style.height = targetBounds.top + utils.getScrollTop() - margin + 'px'; // right div: el = this.element.right; el.style.top = targetBounds.top + utils.getScrollTop() - margin + 'px'; el.style.left = targetBounds.left + targetBounds.width + utils.getScrollLeft() + margin + 'px'; el.style.width = window.screen.width - (targetBounds.left + targetBounds.width + utils.getScrollLeft() + margin) + 'px'; el.style.height = targetBounds.height + margin * 2 + 'px'; // bottom div: el = this.element.bottom; el.style.top = targetBounds.top + utils.getScrollTop() + targetBounds.height + margin + 'px'; el.style.left = '0px'; el.style.width = window.screen.width + 'px'; el.style.height = documentHeight - (targetBounds.top + utils.getScrollTop() + targetBounds.height + margin) + 'px'; // left div: el = this.element.left; el.style.top = targetBounds.top + utils.getScrollTop() + - margin + 'px'; el.style.left = '0px'; el.style.width = targetBounds.left + utils.getScrollLeft() - margin + 'px'; el.style.height = targetBounds.height + margin * 2 + 'px'; }, render: function(step){ // set options for current step: this.stepOpts = {}; utils.extend(this.stepOpts, this.opt); utils.extend(this.stepOpts, step); } }; winHopscotch = new Hopscotch(); context[namespace] = winHopscotch; // Template includes, placed inside a closure to ensure we don't // end up declaring our shim globally. (function(){ var _ = {}; /* * Adapted from the Underscore.js framework. Check it out at * https://github.com/jashkenas/underscore */ _.escape = function(str){ if(customEscape){ return customEscape(str); } if(str == null) return ''; return ('' + str).replace(new RegExp('[&<>"\']', 'g'), function(match){ if(match == '&'){ return '&' } if(match == '<'){ return '<' } if(match == '>'){ return '>' } if(match == '"'){ return '"' } if(match == "'"){ return ''' } }); } this["hopscotch"] = this["hopscotch"] || {}; this["hopscotch"]["templates"] = this["hopscotch"]["templates"] || {}; this["hopscotch"]["templates"]["bubble_default"] = function(obj) { obj || (obj = {}); var __t, __p = '', __e = _.escape, __j = Array.prototype.join; function print() { __p += __j.call(arguments, '') } with (obj) { function optEscape(str, unsafe){ if(unsafe){ return _.escape(str); } return str; } ; __p += '\n
\n '; if(tour.isTour){ ; __p += '' + ((__t = ( i18n.stepNum )) == null ? '' : __t) + ''; } ; __p += '\n
\n '; if(step.title !== ''){ ; __p += '

' + ((__t = ( optEscape(step.title, tour.unsafe) )) == null ? '' : __t) + '

'; } ; __p += '\n '; if(step.content !== ''){ ; __p += '
' + ((__t = ( optEscape(step.content, tour.unsafe) )) == null ? '' : __t) + '
'; } ; __p += '\n
\n
\n '; if(buttons.showPrev){ ; __p += ''; } ; __p += '\n '; if(buttons.showCTA){ ; __p += ''; } ; __p += '\n '; if(buttons.showNext){ ; __p += ''; } ; __p += '\n
\n '; if(buttons.showClose){ ; __p += '' + ((__t = ( i18n.closeTooltip )) == null ? '' : __t) + ''; } ; __p += '\n
\n
\n
\n
\n
'; } return __p }; }()); }(window, 'hopscotch'));