PTMagic/Monitor/wwwroot/assets/plugins/hopscotch/js/hopscotch.js

2536 lines
75 KiB
JavaScript

/**! hopscotch - v0.2.5
*
* Copyright 2015 LinkedIn Corp. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function(context, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS
module.exports = factory();
} else {
var namespace = 'hopscotch';
// Browser globals
if (context[namespace]) {
// Hopscotch already exists.
return;
}
context[namespace] = factory();
}
}(this, (function() {
var Hopscotch,
HopscotchBubble,
HopscotchCalloutManager,
HopscotchI18N,
customI18N,
customRenderer,
customEscape,
templateToUse = 'bubble_default',
Sizzle = window.Sizzle || null,
utils,
callbacks,
helpers,
winLoadHandler,
defaultOpts,
winHopscotch,
undefinedStr = 'undefined',
waitingToStart = false, // is a tour waiting for the document to finish
// loading so that it can start?
hasJquery = (typeof jQuery !== undefinedStr),
hasSessionStorage = false,
isStorageWritable = false,
document = window.document,
validIdRegEx = /^[a-zA-Z]+[a-zA-Z0-9_-]*$/,
rtlMatches = {
left: 'right',
right: 'left'
};
// If cookies are disabled, accessing sessionStorage can throw an error.
// sessionStorage could also throw an error in Safari on write (even though it exists).
// So, we'll try writing to sessionStorage to verify it's available.
try {
if(typeof window.sessionStorage !== undefinedStr){
hasSessionStorage = true;
sessionStorage.setItem('hopscotch.test.storage', 'ok');
sessionStorage.removeItem('hopscotch.test.storage');
isStorageWritable = true;
}
} catch (err) {}
defaultOpts = {
smoothScroll: true,
scrollDuration: 1000,
scrollTopMargin: 200,
showCloseButton: true,
showPrevButton: false,
showNextButton: true,
bubbleWidth: 280,
bubblePadding: 15,
arrowWidth: 20,
skipIfNoElement: true,
isRtl: false,
cookieName: 'hopscotch.tour.state'
};
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<len; ++i) {
this.invokeCallback(cbArr[i].cb);
}
},
/**
* @private
*/
getScrollTop: function() {
var scrollTop;
if (typeof window.pageYOffset !== undefinedStr) {
scrollTop = window.pageYOffset;
}
else {
// Most likely IE <=8, which doesn't support pageYOffset
scrollTop = document.documentElement.scrollTop;
}
return scrollTop;
},
/**
* @private
*/
getScrollLeft: function() {
var scrollLeft;
if (typeof window.pageXOffset !== undefinedStr) {
scrollLeft = window.pageXOffset;
}
else {
// Most likely IE <=8, which doesn't support pageXOffset
scrollLeft = document.documentElement.scrollLeft;
}
return scrollLeft;
},
/**
* @private
*/
getWindowHeight: function() {
return window.innerHeight || document.documentElement.clientHeight;
},
/**
* @private
*/
addEvtListener: function(el, evtName, fn) {
if(el) {
return el.addEventListener ? el.addEventListener(evtName, fn, false) : el.attachEvent('on' + evtName, fn);
}
},
/**
* @private
*/
removeEvtListener: function(el, evtName, fn) {
if(el) {
return el.removeEventListener ? el.removeEventListener(evtName, fn, false) : el.detachEvent('on' + evtName, fn);
}
},
documentIsReady: function() {
return document.readyState === 'complete';
},
/**
* @private
*/
evtPreventDefault: function(evt) {
if (evt.preventDefault) {
evt.preventDefault();
}
else if (event) {
event.returnValue = false;
}
},
/**
* @private
*/
extend: function(obj1, obj2) {
var prop;
for (prop in obj2) {
if (obj2.hasOwnProperty(prop)) {
obj1[prop] = obj2[prop];
}
}
},
/**
* Helper function to get a single target DOM element. We will try to
* locate the DOM element through several ways, in the following order:
*
* 1) Passing the string into document.querySelector
* 2) Passing the string to jQuery, if it exists
* 3) Passing the string to Sizzle, if it exists
* 4) Calling document.getElementById if it is a plain id
*
* Default case is to assume the string is a plain id and call
* document.getElementById on it.
*
* @private
*/
getStepTargetHelper: function(target){
var result = document.getElementById(target);
//Backwards compatibility: assume the string is an id
if (result) {
return result;
}
if (hasJquery) {
result = jQuery(target);
return result.length ? result[0] : null;
}
if (Sizzle) {
result = new Sizzle(target);
return result.length ? result[0] : null;
}
if (document.querySelector) {
try {
return document.querySelector(target);
} catch (err) {}
}
// Regex test for id. Following the HTML 4 spec for valid id formats.
// (http://www.w3.org/TR/html4/types.html#type-id)
if (/^#[a-zA-Z][\w-_:.]*$/.test(target)) {
return document.getElementById(target.substring(1));
}
return null;
},
/**
* Given a step, returns the target DOM element associated with it. It is
* recommended to only assign one target per step. However, there are
* some use cases which require multiple step targets to be supplied. In
* this event, we will use the first target in the array that we can
* locate on the page. See the comments for getStepTargetHelper for more
* information.
*
* @private
*/
getStepTarget: function(step) {
var queriedTarget;
if (!step || !step.target) {
return null;
}
if (typeof step.target === 'string') {
//Just one target to test. Check and return its results.
return utils.getStepTargetHelper(step.target);
}
else if (Array.isArray(step.target)) {
// Multiple items to check. Check each and return the first success.
// Assuming they are all strings.
var i,
len;
for (i = 0, len = step.target.length; i < len; i++){
if (typeof step.target[i] === 'string') {
queriedTarget = utils.getStepTargetHelper(step.target[i]);
if (queriedTarget) {
return queriedTarget;
}
}
}
return null;
}
// Assume that the step.target is a DOM element
return step.target;
},
/**
* Convenience method for getting an i18n string. Returns custom i18n value
* or the default i18n value if no custom value exists.
*
* @private
*/
getI18NString: function(key) {
return customI18N[key] || HopscotchI18N[key];
},
// Tour session persistence for multi-page tours. Uses HTML5 sessionStorage if available, then
// falls back to using cookies.
//
// The following cookie-related logic is borrowed from:
// http://www.quirksmode.org/js/cookies.html
/**
* @private
*/
setState: function(name,value,days) {
var expires = '',
date;
if (hasSessionStorage && isStorageWritable) {
try{
sessionStorage.setItem(name, value);
}
catch(err){
isStorageWritable = false;
this.setState(name, value, days);
}
}
else {
if(hasSessionStorage){
//Clear out existing sessionStorage key so the new value we set to cookie gets read.
//(If we're here, we've run into an error while trying to write to sessionStorage).
sessionStorage.removeItem(name);
}
if (days) {
date = new Date();
date.setTime(date.getTime()+(days*24*60*60*1000));
expires = '; expires='+date.toGMTString();
}
document.cookie = name+'='+value+expires+'; path=/';
}
},
/**
* @private
*/
getState: function(name) {
var nameEQ = name + '=',
ca = document.cookie.split(';'),
i,
c,
state;
//return value from session storage if we have it
if (hasSessionStorage) {
state = sessionStorage.getItem(name);
if(state){
return state;
}
}
//else, try cookies
for(i=0;i < ca.length;i++) {
c = ca[i];
while (c.charAt(0)===' ') {c = c.substring(1,c.length);}
if (c.indexOf(nameEQ) === 0) {
state = c.substring(nameEQ.length,c.length);
break;
}
}
return state;
},
/**
* @private
*/
clearState: function(name) {
if (hasSessionStorage) {
sessionStorage.removeItem(name);
}
else {
this.setState(name,'',-1);
}
},
/**
* Originally called it orientation, but placement is more intuitive.
* Allowing both for now for backwards compatibility.
* @private
*/
normalizePlacement: function(step) {
if (!step.placement && step.orientation) {
step.placement = step.orientation;
}
},
/**
* If step is right-to-left enabled, flip the placement and xOffset, but only once.
* @private
*/
flipPlacement: function(step){
if(step.isRtl && !step._isFlipped){
var props = ['orientation', 'placement'], prop, i;
if(step.xOffset){
step.xOffset = -1 * this.getPixelValue(step.xOffset);
}
for(i in props){
prop = props[i];
if(step.hasOwnProperty(prop) && rtlMatches.hasOwnProperty(step[prop])) {
step[prop] = rtlMatches[step[prop]];
}
}
step._isFlipped = true;
}
}
};
utils.addEvtListener(window, 'load', winLoadHandler);
callbacks = {
next: [],
prev: [],
start: [],
end: [],
show: [],
error: [],
close: []
};
/**
* helpers
* =======
* A map of functions to be used as callback listeners. Functions are
* added to and removed from the map using the functions
* Hopscotch.registerHelper() and Hopscotch.unregisterHelper().
*/
helpers = {};
HopscotchI18N = {
stepNums: null,
nextBtn: 'Next',
prevBtn: 'Back',
doneBtn: 'Done',
skipBtn: 'Skip',
closeTooltip: 'Close'
};
customI18N = {}; // Developer's custom i18n strings goes here.
/**
* HopscotchBubble
*
* @class The HopscotchBubble class represents the view of a bubble. This class is also used for Hopscotch callouts.
*/
HopscotchBubble = function(opt) {
this.init(opt);
};
HopscotchBubble.prototype = {
isShowing: false,
currStep: undefined,
/**
* setPosition
*
* Sets the position of the bubble using the bounding rectangle of the
* target element and the orientation and offset information specified by
* the JSON.
*/
setPosition: function(step) {
var bubbleBoundingHeight,
bubbleBoundingWidth,
boundingRect,
top,
left,
arrowOffset,
verticalLeftPosition,
targetEl = utils.getStepTarget(step),
el = this.element,
arrowEl = this.arrowEl,
arrowPos = step.isRtl ? 'right' : 'left';
utils.flipPlacement(step);
utils.normalizePlacement(step);
bubbleBoundingWidth = el.offsetWidth;
bubbleBoundingHeight = el.offsetHeight;
utils.removeClass(el, 'fade-in-down fade-in-up fade-in-left fade-in-right');
// SET POSITION
boundingRect = targetEl.getBoundingClientRect();
verticalLeftPosition = step.isRtl ? boundingRect.right - bubbleBoundingWidth : boundingRect.left;
if (step.placement === 'top') {
top = (boundingRect.top - bubbleBoundingHeight) - this.opt.arrowWidth;
left = verticalLeftPosition;
}
else if (step.placement === 'bottom') {
top = boundingRect.bottom + this.opt.arrowWidth;
left = verticalLeftPosition;
}
else if (step.placement === 'left') {
top = boundingRect.top;
left = boundingRect.left - bubbleBoundingWidth - this.opt.arrowWidth;
}
else if (step.placement === 'right') {
top = boundingRect.top;
left = boundingRect.right + this.opt.arrowWidth;
}
else {
throw new Error('Bubble placement failed because step.placement is invalid or undefined!');
}
// SET (OR RESET) ARROW OFFSETS
if (step.arrowOffset !== 'center') {
arrowOffset = utils.getPixelValue(step.arrowOffset);
}
else {
arrowOffset = step.arrowOffset;
}
if (!arrowOffset) {
arrowEl.style.top = '';
arrowEl.style[arrowPos] = '';
}
else if (step.placement === 'top' || step.placement === 'bottom') {
arrowEl.style.top = '';
if (arrowOffset === 'center') {
arrowEl.style[arrowPos] = Math.floor((bubbleBoundingWidth / 2) - arrowEl.offsetWidth/2) + 'px';
}
else {
// Numeric pixel value
arrowEl.style[arrowPos] = arrowOffset + 'px';
}
}
else if (step.placement === 'left' || step.placement === 'right') {
arrowEl.style[arrowPos] = '';
if (arrowOffset === 'center') {
arrowEl.style.top = Math.floor((bubbleBoundingHeight / 2) - arrowEl.offsetHeight/2) + 'px';
}
else {
// Numeric pixel value
arrowEl.style.top = arrowOffset + 'px';
}
}
// HORIZONTAL OFFSET
if (step.xOffset === 'center') {
left = (boundingRect.left + targetEl.offsetWidth/2) - (bubbleBoundingWidth / 2);
}
else {
left += utils.getPixelValue(step.xOffset);
}
// VERTICAL OFFSET
if (step.yOffset === 'center') {
top = (boundingRect.top + targetEl.offsetHeight/2) - (bubbleBoundingHeight / 2);
}
else {
top += utils.getPixelValue(step.yOffset);
}
// ADJUST TOP FOR SCROLL POSITION
if (!step.fixedElement) {
top += utils.getScrollTop();
left += utils.getScrollLeft();
}
// ACCOUNT FOR FIXED POSITION ELEMENTS
el.style.position = (step.fixedElement ? 'fixed' : 'absolute');
el.style.top = top + 'px';
el.style.left = left + 'px';
},
/**
* Renders the bubble according to the step JSON.
*
* @param {Object} step Information defining how the bubble should look.
* @param {Number} idx The index of the step in the tour. Not used for callouts.
* @param {Function} callback Function to be invoked after rendering is finished.
*/
render: function(step, idx, callback) {
var el = this.element,
tourSpecificRenderer,
customTourData,
unsafe,
currTour,
totalSteps,
totalStepsI18n,
nextBtnText,
isLast,
opts;
// Cache current step information.
if (step) {
this.currStep = step;
}
else if (this.currStep) {
step = this.currStep;
}
// Check current tour for total number of steps and custom render data
if(this.opt.isTourBubble){
currTour = winHopscotch.getCurrTour();
if(currTour){
customTourData = currTour.customData;
tourSpecificRenderer = currTour.customRenderer;
step.isRtl = step.hasOwnProperty('isRtl') ? step.isRtl :
(currTour.hasOwnProperty('isRtl') ? currTour.isRtl : this.opt.isRtl);
unsafe = currTour.unsafe;
if(Array.isArray(currTour.steps)){
totalSteps = currTour.steps.length;
totalStepsI18n = this._getStepI18nNum(this._getStepNum(totalSteps - 1));
isLast = (this._getStepNum(idx) === this._getStepNum(totalSteps - 1));
}
}
}else{
customTourData = step.customData;
tourSpecificRenderer = step.customRenderer;
unsafe = step.unsafe;
step.isRtl = step.hasOwnProperty('isRtl') ? step.isRtl : this.opt.isRtl;
}
// Determine label for next button
if(isLast){
nextBtnText = utils.getI18NString('doneBtn');
} else if(step.showSkip) {
nextBtnText = utils.getI18NString('skipBtn');
} else {
nextBtnText = utils.getI18NString('nextBtn');
}
utils.flipPlacement(step);
utils.normalizePlacement(step);
this.placement = step.placement;
// Setup the configuration options we want to pass along to the template
opts = {
i18n: {
prevBtn: utils.getI18NString('prevBtn'),
nextBtn: nextBtnText,
closeTooltip: utils.getI18NString('closeTooltip'),
stepNum: this._getStepI18nNum(this._getStepNum(idx)),
numSteps: totalStepsI18n
},
buttons:{
showPrev: (utils.valOrDefault(step.showPrevButton, this.opt.showPrevButton) && (this._getStepNum(idx) > 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 || ''),
isRtl: step.isRtl,
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(!winHopscotch.templates || (typeof winHopscotch.templates[tourSpecificRenderer] !== 'function')){
throw new Error('Bubble rendering failed - template "' + tourSpecificRenderer + '" is not a function.');
}
el.innerHTML = winHopscotch.templates[tourSpecificRenderer](opts);
}
else if(customRenderer){
el.innerHTML = customRenderer(opts);
}
else{
if(!winHopscotch.templates || (typeof winHopscotch.templates[templateToUse] !== 'function')){
throw new Error('Bubble rendering failed - template "' + templateToUse + '" is not a function.');
}
el.innerHTML = winHopscotch.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 = (typeof step.zindex === 'number') ? 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 step number considering steps that were skipped because their target wasn't found
*
* @private
*/
_getStepNum: function(idx) {
var skippedStepsCount = 0,
stepIdx,
skippedSteps = winHopscotch.getSkippedStepsIndexes(),
i,
len = skippedSteps.length;
//count number of steps skipped before current step
for(i = 0; i < len; i++) {
stepIdx = skippedSteps[i];
if(stepIdx<idx) {
skippedStepsCount++;
}
}
return idx - skippedStepsCount;
},
/**
* 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(placement) {
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 (placement === 'top') {
utils.addClass(this.arrowEl, 'down');
}
else if (placement === 'bottom') {
utils.addClass(this.arrowEl, 'up');
}
else if (placement === 'left') {
utils.addClass(this.arrowEl, 'right');
}
else if (placement === '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;
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;
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;
// Override evt for IE8 as IE8 doesn't pass event but binds it to window
evt = evt || window.event; // get window.event if argument is falsy (in IE)
// get srcElement if target is falsy (IE)
var targetElement = evt.target || evt.srcElement;
//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(targetElement);
//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,
currTour,
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,
isRtl: defaultOpts.isRtl,
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');
} else {
currTour = winHopscotch.getCurrTour();
if(currTour){
utils.addClass(el, 'tour-' + currTour.id);
}
}
/**
* 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);
}
else {
// Moz, webkit, Opera
if (document.addEventListener) {
appendToBody = function() {
document.removeEventListener('DOMContentLoaded', appendToBody);
window.removeEventListener('load', appendToBody);
document.body.appendChild(el);
};
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);
}
};
document.attachEvent('onreadystatechange', appendToBody);
}
utils.addEvtListener(window, 'load', appendToBody);
}
}
};
/**
* HopscotchCalloutManager
*
* @class Manages the creation and destruction of single callouts.
* @constructor
*/
HopscotchCalloutManager = function() {
var callouts = {},
calloutOpts = {};
/**
* 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(!validIdRegEx.test(opt.id)) {
throw new Error('Callout ID is using an invalid format. Use alphanumeric, underscores, and/or hyphens only. First character must be a letter.');
}
if (callouts[opt.id]) {
throw new Error('Callout by that id already exists. Please choose a unique id.');
}
if (!utils.getStepTarget(opt)) {
throw new Error('Must specify existing target element via \'target\' option.');
}
opt.showNextButton = opt.showPrevButton = false;
opt.isTourBubble = false;
callout = new HopscotchBubble(opt);
callouts[opt.id] = callout;
calloutOpts[opt.id] = opt;
callout.render(opt, null, function() {
callout.show();
if (opt.onShow) {
utils.invokeCallback(opt.onShow);
}
});
}
else {
throw new Error('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;
for (calloutId in callouts) {
if (callouts.hasOwnProperty(calloutId)) {
this.removeCallout(calloutId);
}
}
};
/**
* removeCallout
*
* 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;
calloutOpts[id] = null;
if (!callout) { return; }
callout.destroy();
};
/**
* refreshCalloutPositions
*
* Refresh the positions for all callouts known by the
* callout manager. Typically you'll use
* hopscotch.refreshBubblePosition() to refresh ALL
* bubbles instead of calling this directly.
*/
this.refreshCalloutPositions = function(){
var calloutId,
callout,
opts;
for (calloutId in callouts) {
if (callouts.hasOwnProperty(calloutId) && calloutOpts.hasOwnProperty(calloutId)) {
callout = callouts[calloutId];
opts = calloutOpts[calloutId];
if(callout && opts){
callout.setPosition(opts);
}
}
}
};
};
/**
* 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,
skippedSteps = {},
cookieTourId,
cookieTourStep,
cookieSkippedSteps = [],
_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.element || !bubble.element.parentNode) {
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'),
isRtl: getOption('isRtl')
});
}
return bubble;
},
/**
* Destroy the bubble currently associated with Hopscotch.
* This is done when we end the current tour.
*
* @private
*/
destroyBubble = function() {
if(bubble){
bubble.destroy();
bubble = null;
}
},
/**
* 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 (!currTour || 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) {
//this step was previously skipped, but now its target exists,
//remove this step from skipped steps set
if(skippedSteps[currStepNum]) {
delete skippedSteps[currStepNum];
}
// We're done! Return the step number via the callback.
cb(currStepNum);
}
else {
//mark this step as skipped, since its target wasn't found
skippedSteps[currStepNum] = true;
// 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();
if (step.nextOnTargetClick) {
// Detach the listener when tour is moving to a different step
utils.removeEvtListener(utils.getStepTarget(step), 'click', targetClickNextFn);
}
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
setStateHelper();
// 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);
} else if (currStepNum + direction === currTour.steps.length) {
return this.endTour();
}
return this;
},
/**
* loadTour
*
* Loads, but does not display, tour.
*
* @private
* @param tour The tour JSON object
*/
loadTour = function(tour) {
var tmpOpt = {},
prop,
tourState,
tourStateValues;
// 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) {
tourStateValues = tourState.split(':');
cookieTourId = tourStateValues[0]; // selecting tour is not supported by this framework.
cookieTourStep = tourStateValues[1];
if(tourStateValues.length > 2) {
cookieSkippedSteps = tourStateValues[2].split(',');
}
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, savedSkippedSteps, cb) {
var step,
target;
currStepNum = startStepNum || 0;
skippedSteps = savedSkippedSteps || {};
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');
//this step was skipped, since its target does not exist
skippedSteps[currStepNum] = true;
if (getOption('skipIfNoElement')) {
goToStepWithTarget(1, cb);
return;
}
else {
currStepNum = -1;
cb(currStepNum);
}
}
},
showStepHelper = function(stepNum) {
var step = currTour.steps[stepNum],
bubble = getBubble(),
targetEl = utils.getStepTarget(step);
function showBubble() {
bubble.show();
utils.invokeEventCallbacks('show', step.onShow);
}
if (currStepNum !== stepNum && getCurrStep().nextOnTargetClick) {
// Detach the listener when tour is moving to a different step
utils.removeEvtListener(utils.getStepTarget(getCurrStep()), 'click', targetClickNextFn);
}
// Update bubble for current step
currStepNum = stepNum;
bubble.hide(false);
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);
}
});
setStateHelper();
},
setStateHelper = function() {
var cookieVal = currTour.id + ':' + currStepNum,
skipedStepIndexes = winHopscotch.getSkippedStepsIndexes();
if(skipedStepIndexes && skipedStepIndexes.length > 0) {
cookieVal += ':' + skipedStepIndexes.join(',');
}
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,
skippedSteps = {},
self = this;
// loadTour if we are calling startTour directly. (When we call startTour
// from window onLoad handler, we'll use currTour)
if (!currTour) {
// Sanity check! Is there a tour?
if(!tour){
throw new Error('Tour data is required for startTour.');
}
// Check validity of tour ID. If invalid, throw an error.
if(!tour.id || !validIdRegEx.test(tour.id)) {
throw new Error('Tour ID is using an invalid format. Use alphanumeric, underscores, and/or hyphens only. First character must be a letter.');
}
currTour = tour;
loadTour.call(this, tour);
}
if (typeof stepNum !== undefinedStr) {
if (stepNum >= currTour.steps.length) {
throw new Error('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;
if(cookieSkippedSteps.length > 0){
for(var i = 0, len = cookieSkippedSteps.length; i < len; i++) {
skippedSteps[cookieSkippedSteps[i]] = true;
}
}
}
else if (!currStepNum) {
currStepNum = 0;
}
// Find the current step we should begin the tour on, and then actually start the tour.
findStartingStep(currStepNum, skippedSteps, 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(!utils.getStepTarget(step)) {
return;
}
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) {
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(),
currentStep;
clearState = utils.valOrDefault(clearState, true);
doCallbacks = utils.valOrDefault(doCallbacks, true);
//remove event listener if current step had it added
if(currTour) {
currentStep = getCurrStep();
if(currentStep && currentStep.nextOnTargetClick) {
utils.removeEvtListener(utils.getStepTarget(currentStep), 'click', targetClickNextFn);
}
}
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();
destroyBubble();
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;
};
/**
* getSkippedStepsIndexes
*
* @return {Array} Array of skipped step indexes
*/
this.getSkippedStepsIndexes = function() {
var skippedStepsIdxArray = [],
stepIds;
for(stepIds in skippedSteps){
skippedStepsIdxArray.push(stepIds);
}
return skippedStepsIdxArray;
};
/**
* refreshBubblePosition
*
* Tell hopscotch that the position of the current tour element changed
* and the bubble therefore needs to be redrawn. Also refreshes position
* of all Hopscotch Callouts on the page.
*
* @returns {Object} Hopscotch
*/
this.refreshBubblePosition = function() {
var currStep = getCurrStep();
if(currStep){
getBubble().setPosition(currStep);
}
this.getCalloutManager().refreshCalloutPositions();
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
*
* <pre>
* 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.
* - isRtl: Boolean - Set to true when instantiating in a right-to-left
* language environment, or if mirrored positioning is
* needed.
* Defaults to FALSE.
*
* - i18n: Object - For i18n purposes. Allows you to change the
* text of button labels and step numbers.
* - i18n.stepNums: Array\<String\> - Provide a list of strings to be shown as
* the step number, based on index of array. Unicode
* characters are supported. (e.g., ['&#x4e00;',
* '&#x4e8c;', '&#x4e09;']) If there are more steps
* than provided numbers, Arabic numerals
* ('4', '5', '6', etc.) will be used as default.
* // =========
* // 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<STRING> - Provide a list of strings to be shown as
* the step number, based on index of array. Unicode
* characters are supported. (e.g., ['&#x4e00;',
* '&#x4e8c;', '&#x4e09;']) If there are more steps
* than provided numbers, Arabic numerals
* ('4', '5', '6', etc.) will be used as default.
* </pre>
*
* @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);
};
winHopscotch = new Hopscotch();
// 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 '&amp;' }
if(match == '<'){ return '&lt;' }
if(match == '>'){ return '&gt;' }
if(match == '"'){ return '&quot;' }
if(match == "'"){ return '&#x27;' }
});
}
this["templates"] = this["templates"] || {};
this["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<div class="hopscotch-bubble-container" style="width: ' +
((__t = ( step.width )) == null ? '' : __t) +
'px; padding: ' +
((__t = ( step.padding )) == null ? '' : __t) +
'px;">\n ';
if(tour.isTour){ ;
__p += '<span class="hopscotch-bubble-number">' +
((__t = ( i18n.stepNum )) == null ? '' : __t) +
'</span>';
} ;
__p += '\n <div class="hopscotch-bubble-content">\n ';
if(step.title !== ''){ ;
__p += '<h3 class="hopscotch-title">' +
((__t = ( optEscape(step.title, tour.unsafe) )) == null ? '' : __t) +
'</h3>';
} ;
__p += '\n ';
if(step.content !== ''){ ;
__p += '<div class="hopscotch-content">' +
((__t = ( optEscape(step.content, tour.unsafe) )) == null ? '' : __t) +
'</div>';
} ;
__p += '\n </div>\n <div class="hopscotch-actions">\n ';
if(buttons.showPrev){ ;
__p += '<button class="hopscotch-nav-button prev hopscotch-prev">' +
((__t = ( i18n.prevBtn )) == null ? '' : __t) +
'</button>';
} ;
__p += '\n ';
if(buttons.showCTA){ ;
__p += '<button class="hopscotch-nav-button next hopscotch-cta">' +
((__t = ( buttons.ctaLabel )) == null ? '' : __t) +
'</button>';
} ;
__p += '\n ';
if(buttons.showNext){ ;
__p += '<button class="hopscotch-nav-button next hopscotch-next">' +
((__t = ( i18n.nextBtn )) == null ? '' : __t) +
'</button>';
} ;
__p += '\n </div>\n ';
if(buttons.showClose){ ;
__p += '<button class="hopscotch-bubble-close hopscotch-close">' +
((__t = ( i18n.closeTooltip )) == null ? '' : __t) +
'</button>';
} ;
__p += '\n</div>\n<div class="hopscotch-bubble-arrow-container hopscotch-arrow">\n <div class="hopscotch-bubble-arrow-border"></div>\n <div class="hopscotch-bubble-arrow"></div>\n</div>';
}
return __p
};
}.call(winHopscotch));
return winHopscotch;
})));