/**! 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, /* epages adaptions */ HopscotchHighlight, HopscotchTask, /* epages adaptions */ 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: 400, bubblePadding: 15, arrowWidth: 20, skipIfNoElement: true, isRtl: false, cookieName: 'hopscotch.tour.state' /* epages Adaptions */ ,highlight: true ,highlightMargin: 2 ,highlightBottomMargin: 0 ,highlightBorderRadius: false ,task: null ,iframe: false /* epages Adaptions */ }; 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]; } } }, /* epages highlight Adaptions */ /** * @private */ getWindowWidth: function() { return window.innerWidth || document.documentElement.clientWidth; }, getIframeOffsets : function(step){ var iframe = {element:null,right:0,left:0,top:0,bottom:0}; if (step.iframe){ iframe.element = document.querySelector(step.iframe); if (iframe.element){ var iframeRect = iframe.element.getBoundingClientRect(); iframe.top=iframeRect.top, iframe.right=iframeRect.right, iframe.bottom=iframeRect.bottom, iframe.left=iframeRect.left; } } return iframe; }, /* epages highlight Adaptions */ /** * 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; } // make it possible to use a custom selector function for the target (e.g. when contained in an iframe) if (typeof step.target === "function") { return step.target(); } else 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 = { /* epages adaption */ stepIsMandatory: 'Please finish the given task to continue', /* epages adaption */ }; // 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, self = this, 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'); var iframe = utils.getIframeOffsets(step); // SET POSITION boundingRect = targetEl.getBoundingClientRect(); verticalLeftPosition = step.isRtl ? iframe.right + boundingRect.right - bubbleBoundingWidth : iframe.left + boundingRect.left; if (step.placement === 'top') { top = iframe.top + (boundingRect.top - bubbleBoundingHeight) - this.opt.arrowWidth; left = verticalLeftPosition; } else if (step.placement === 'bottom') { top = iframe.top + boundingRect.bottom + this.opt.arrowWidth; left = verticalLeftPosition; } else if (step.placement === 'left') { top = iframe.top + boundingRect.top; left = iframe.left + boundingRect.left - bubbleBoundingWidth - this.opt.arrowWidth; } else if (step.placement === 'right') { top = iframe.top + boundingRect.top; left = iframe.right + boundingRect.right + this.opt.arrowWidth; } else { throw new Error('Bubble placement failed because step.placement is invalid or undefined!'); } /* epages highlight adaptions*/ // UPDATE HIGHLIGHT self.highlight.setPosition(step, targetEl); if (step.hideArrow === true) { arrowEl.style.visibility = 'hidden'; } else { arrowEl.style.visibility = 'visible'; } /* epages highlight adaptions*/ // 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 += (targetEl.offsetWidth/2) - (bubbleBoundingWidth / 2); } else { left += utils.getPixelValue(step.xOffset); } // VERTICAL OFFSET if (step.yOffset === 'center') { 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; } /* epages highlight adaptions */ // update highlight with current step information this.highlight.render(step); this.task.prepare(step); /* epages highlight adaptions */ // 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 /* epages task adaptions*/ ,taskHeading: utils.getI18NString('taskHeading') /* epages task adaptions*/ }, 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 || {}) /* epages task adaptions*/ ,task:(step.task||null) /* epages task adaptions*/ }, 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; /* epages hightlight adaptions */ this.highlight.show(); this.task.taskSetup(); /* epages hightlight adaptions */ 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; /* epages hightlight adaptions */ this.highlight.hide(); this.task.taskTearDown(); /* epages hightlight adaptions */ 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); } } /* epages adaptions*/ self.highlight = new HopscotchHighlight(initOpt); self.task = new HopscotchTask(initOpt); /* epages adaptions*/ /** * 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); /* epages adaptions*/ self.highlight.addToDom(); //self.task.taskSetup(); /* epages adaptions*/ } else { // Moz, webkit, Opera if (document.addEventListener) { appendToBody = function() { document.removeEventListener('DOMContentLoaded', appendToBody); window.removeEventListener('load', appendToBody); document.body.appendChild(el); /* epages adaptions*/ self.highlight.addToDom(); //self.task.taskSetup(); /* epages adaptions*/ }; 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); /* epages adaptions*/ self.highlight.addToDom(); //self.task.taskSetup(); /* epages adaptions*/ } }; 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 && target.offsetParent) { //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); /* epages highlight adaptions*/ bubble.highlight.setPosition(step, ep(step.target)[0]); /* epages highlight adaptions end*/ } 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. * - highlight: Boolean - Shows an overlay that highlights the selected element * Defaults to TRUE. * - highlightMargin: Number - Amount of margin around the selected element to show * Defaults to 0 * // ========= * // CALLBACKS * // ========= * - onNext: Function - Invoked after every click on a "Next" button. * - onPrev: Function - Invoked after every click on a "Prev" button. * - onStart: Function - Invoked when the tour is started. * - onEnd: Function - Invoked when the tour ends. * - onClose: Function - Invoked when the user closes the tour before finishing. * - onError: Function - Invoked when the specified target element doesn't exist on the page. * * // ==== * // I18N * // ==== * i18n: OBJECT - For i18n purposes. Allows you to change the text * of button labels and step numbers. * i18n.nextBtn: STRING - Label for next button * i18n.prevBtn: STRING - Label for prev button * i18n.doneBtn: STRING - Label for done button * i18n.skipBtn: STRING - Label for skip button * i18n.closeTooltip: STRING - Text for close button tooltip * i18n.stepNums: ARRAY<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); }; /* epages highlight adaptions*/ HopscotchHighlight = function(opt) { this.init(opt); }; HopscotchHighlight.prototype = { init: function(initOpt) { var opt; var el = { overlay : document.createElement('div'), focus : document.createElement('div'), top: document.createElement('div'), left: document.createElement('div'), right: document.createElement('div'), bottom: document.createElement('div') }; this.element = el; //Merge highlight options with defaults. opt = { highlight: defaultOpts.highlight, highlightMargin: defaultOpts.highlightMargin, highlightBottomMargin: defaultOpts.highlightBottomMargin, highlightBorderRadius: defaultOpts.highlightBorderRadius }; initOpt = (typeof initOpt === undefinedStr ? {} : initOpt); utils.extend(opt, initOpt); this.opt = opt; utils.addClass(this.element.overlay, 'hopscotch-overlay'); utils.addClass(this.element.focus, 'focus'); utils.addClass(this.element.top, 'top'); utils.addClass(this.element.left, 'left'); utils.addClass(this.element.right, 'right'); utils.addClass(this.element.bottom, 'bottom'); }, addToDom: function(){ this.element.focus.appendChild(this.element.top); this.element.focus.appendChild(this.element.left); this.element.focus.appendChild(this.element.right); this.element.focus.appendChild(this.element.bottom); this.element.overlay.appendChild(this.element.focus); document.body.appendChild(this.element.overlay); }, show: function(){ // check if step has disabled the highlight: if (!this.stepOpts.highlight){ return; } if (this.stepOpts !== undefined && this.stepOpts.noScroll){ document.body.style.overflow = 'hidden'; } utils.removeClass(this.element.overlay, 'hide'); if (!this.stepOpts.task){ utils.addClass(this.element.focus,'preventClick'); } else { utils.removeClass(this.element.focus,'preventClick'); } }, hide: function(){ utils.addClass(this.element.overlay, 'hide'); if (this.stepOpts !== undefined && this.stepOpts.noScroll){ document.body.style.overflow = 'initial'; } }, setPosition: function(step, targetEl){ // check if step has disabled the highlight: if (!this.stepOpts.highlight){ return; } var targetBounds = targetEl.getBoundingClientRect(); var iframe = utils.getIframeOffsets(step); var margin = this.stepOpts.highlightMargin; var bottomMargin = margin + this.stepOpts.highlightBottomMargin; var body = document.body, html = document.documentElement; var documentHeight = Math.max( body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight ); var documentWidth = Math.max( body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth ); //console.log(`height: ${documentHeight}`); this.element.overlay.style.height = documentHeight +'px'; this.element.overlay.style.width = documentWidth +'px'; var el = this.element.focus; // setting the dimensions of the focus area el.style.top = iframe.top + targetBounds.top + utils.getScrollTop() - margin + 'px'; el.style.left = iframe.left + targetBounds.left + utils.getScrollLeft() - margin + 'px'; el.style.width = targetBounds.width + margin*2 + 'px'; el.style.height = (this.stepOpts.hideFocus === true) ? '0px' : targetBounds.height + margin + bottomMargin + 'px'; // match the border radius of the target element el.style.borderRadius = (this.stepOpts.highlightBorderRadius !== false) ? this.stepOpts.highlightBorderRadius : window.getComputedStyle(targetEl).borderRadius; }, render: function(step){ // set options for current step: this.stepOpts = {}; utils.extend(this.stepOpts, this.opt); utils.extend(this.stepOpts, step); } }; /* epages highlight adaptions*/ /* epages task adaptions*/ HopscotchTask = function(opt) { this.init(opt); }; HopscotchTask.prototype = { init: function(initOpt) { var opt; // merge options opt = { task: defaultOpts.task }; initOpt = (typeof initOpt === undefinedStr ? {} : initOpt); utils.extend(opt, initOpt); this.opt = opt; //console.log("init function called for "+JSON.stringify(opt)); }, prepare: function(step){ // console.log("preparing: "+JSON.stringify(step)); // set options for current step: this.stepOpts = {}; utils.extend(this.stepOpts, this.opt); utils.extend(this.stepOpts, step); }, taskSetup: function(){ // setup task only if step has one // console.log("task setup for: "+JSON.stringify(this.stepOpts)); if (!this.stepOpts) return; if (!this.stepOpts.task) return; if (!this.stepOpts.task.taskFunction || typeof this.taskFunctions[this.stepOpts.task.taskFunction] !== "function") return; var task = this.taskFunctions[this.stepOpts.task.taskFunction]; //console.log("task "+task+" can be executed"); //console.log("opts:"+JSON.stringify(this.stepOpts)); this.unregisterCB = task.call(this); }, taskTearDown : function(){ // if necessary, unbind task function (e.g. event listeners) if (typeof this.unregisterCB === "function"){ this.unregisterCB(); //console.log("unbinding task function from target."); } }, taskSolved : function(){ var button = document.querySelector(".hopscotch-actions .hopscotch-next"); button.removeAttribute("disabled"); button.classList.remove("Disabled"); button.classList.add("solved"); var taskContainer = document.querySelector(".hopscotch-task"); taskContainer.classList.add("solved"); }, taskNotSolved : function(cannotContinue){ if (this.stepOpts.task.mandatory || cannotContinue){ var button = document.querySelector(".hopscotch-actions .hopscotch-next"); button.setAttribute("disabled", "true"); button.classList.add("Disabled"); button.classList.remove("solved"); } var taskContainer = document.querySelector(".hopscotch-task"); taskContainer.classList.remove("solved"); }, taskFunctions : { clickOnTarget : function(){ var self = this, targetContainer = self.stepOpts.task.target?self.stepOpts.task:self.stepOpts, cb, targetEl = utils.getStepTarget(targetContainer); if (ep(targetEl).hasClass('tourTestPassed')) { self.taskSolved(); return "OK"; } ep(targetEl).on('click', validate); cb = function(){ep(targetEl).off('click',validate);return "OK";}; return cb; function validate(){ ep(targetEl).addClass('tourTestPassed'); self.taskSolved(); } }, testDnDUploader : function(){ var self = this, targetTask = self.stepOpts.task.target?self.stepOpts.task:self.stepOpts, targetContainer = self.stepOpts, cb, targetEl = utils.getStepTarget(targetTask), targetDr = utils.getStepTarget(targetContainer); if (ep(targetEl).hasClass('tourTestPassed')) { self.taskSolved(); return "OK"; } ep(targetEl).on('click', validate); ep(targetDr).on('dragover', _hDragOver); ep(targetDr).on('drop', _hDrop); ep('.ep-dropzone-area').on('drop', _hDrop); cb = function(){ ep(targetEl).off('click',validate); ep(targetDr).off('dragover', _hDragOver); ep(targetDr).off('drop', _hDrop); ep('.ep-dropzone-area').off('drop', _hDrop); return "OK"; }; return cb; function validate(){ ep(targetEl).addClass('tourTestPassed'); self.taskSolved(); } function _hDragOver(evt) { evt.stopPropagation(); evt.preventDefault(); // set effect to copy evt.originalEvent.dataTransfer.dropEffect = 'copy'; } // Drop-Handler function _hDrop(evt) { evt.preventDefault(); validate(); } }, validateInputOnTarget : function(){ var self = this, targetContainer = self.stepOpts.task.target?self.stepOpts.task:self.stepOpts, cb, targetEl = utils.getStepTarget(targetContainer); if (targetEl){ var eventType,validatorFn; if (targetEl.classList.contains("cke_placeholder_textarea") && targetEl.id){ // the target is a WYSIWYG field, so the field needs to be updated manually via the CKE 'key' event // since CKE only updates the original form field on the blur event var editor = CKEDITOR.instances[targetEl.id]; eventType = "key"; validatorFn = validateWysiwyg; editor.on(eventType,validatorFn); // trigger the validation event when the step is (re-)visited validateWysiwyg.call(editor); // register the listener to the callback for teardown when leaving the step cb = function(){editor.removeListener(eventType,validatorFn);return "OK";}; } else if (targetEl.classList.contains("wysiwyg_placeholder_textarea")){ // the target is a scribe WYSIWYG field, so the field needs to be updated manually via the scribe 'keyup' event var editor = ep(targetEl); validatorFn = validate; editor.keyup(validatorFn); targetEl.dispatchEvent(new Event('keyup')); // register the listener to the callback for teardown when leaving the step cb = function(){editor.unbind('keyup', validatorFn);return "OK";}; } else { eventType = "keyup"; validatorFn = validate; targetEl.addEventListener( eventType , validatorFn ); // trigger the validation event when the step is (re-)visited targetEl.dispatchEvent(new Event(eventType)); targetEl.focus(); // preventing form auto commit on pressing the enter key // also prevent changing input fields via tab targetEl.addEventListener( "keydown" , function(e){ var forbiddenKeyCodes = { 13 : "enter", 9 : "tab" }; if ( forbiddenKeyCodes[e.keyCode] ){e.preventDefault(); return false;} } ); cb = function(){targetEl.removeEventListener(eventType,validate);return "OK";}; } } else { //console.log("error: could not find target element"); } return cb; function validate(){ var input = this.value; if (targetEl.classList.contains("wysiwyg_placeholder_textarea") && ep(this).siblings('textarea').length !== 0) { input = (ep(this).siblings('textarea')[0].value); } ep(this).trigger("validate"); var invalidClass = ep(this).hasClass("ui-invalid"); if (invalidClass){ // on invalid input the user is not allowed to resume self.taskNotSolved(true); } else if (!input) { // otherwise it's okay to have an empty value for optional parameters self.taskNotSolved(); } else { self.taskSolved(); } } function validateWysiwyg(){ this.updateElement(); if (this.element.$.value){ self.taskSolved(); } else { self.taskNotSolved(); } } } } }; /* epages task adaptions*/ 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; } ; //console.log("template called with the following opts:"+JSON.stringify(step)); __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>'; } ; /* epages task adaptions*/ __p += '\n '; if(step.task){ ; __p += '<div class="hopscotch-task">'+ //'<h4>'+((__t = ( i18n.taskHeading )) == null ? '' : __t) + '</h4>' + '<p>' + ((__t = ( optEscape(step.task.content, tour.unsafe) )) == null ? '' : __t) + '</p></div>'; } ; /* epages task adaptions*/ __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){ ; /* epages task adaptions*/ var mandatory = step.task&&step.task.mandatory; __p += '<button class="hopscotch-nav-button next hopscotch-next Button SaveButton'+(mandatory?" Disabled":"")+'" '+(mandatory?'disabled="disabled" title="'+ ((__t = ( i18n.stepIsMandatory )) == null ? '' : __t)+ '"':'""')+'>' + /* epages task adaptions*/ ((__t = ( i18n.nextBtn )) == null ? '' : __t) + '</button>'; } ; __p += '\n </div>\n '; if(buttons.showClose){ ; __p += '<button class="hopscotch-bubble-close hopscotch-close" title="'+ ((__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; })));