2536 lines
75 KiB
JavaScript
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., ['一',
|
|
* '二', '三']) 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., ['一',
|
|
* '二', '三']) 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 '&' }
|
|
if(match == '<'){ return '<' }
|
|
if(match == '>'){ return '>' }
|
|
if(match == '"'){ return '"' }
|
|
if(match == "'"){ return ''' }
|
|
});
|
|
}
|
|
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;
|
|
|
|
}))); |