import Step from './step';
import Overlay from './overlay';
import NavigationHandler from './navigation_handler';
let Promise = require('es6-promise').Promise;
import Constant from './constants';
class Tutorial {
/**
* <p>The tutorial configuration is where the steps of a tutorial are specified,
* and also allows customization of the overlay style.
* If optional configuration parameters are not required, the steps property
* array can be passed in directly as the configuration.</p>
*
* <p>Notes on implementation:</p>
* <p>The elements defined in each step of a tutorial via
* StepConfiguration.selectors are highlighted using transparent overlays.</p>
* <p>These elements areare overlaid using one of two strategies:</p>
* <ol>
* <li>Semi-transparent overlay with a transparent section cut out over the
* element</li>
* <li>Selected elements are cloned and placed above a transparent overlay</li>
* </ol>
*
* <p>#1 is more performant, but issues arise when an element is not rectangularly-
* shaped, or when it has `:before` or `:after`
* pseudo-selectors that insert new DOM elements that protrude out of the
* main element.</p>
* <p>#2 is slower because of deep CSS style cloning, but it will correctly render
* the entire element in question, regardless of shape or size.</p>
* </p>However, there are edge cases where Firefox
* will not clone CSS `margin` attribute of children elements.
* In those cases, the delegate callbacks should be utilized to fix.
* Note however, that #2 is always chosen if multiple selectors are specified in
* StepConfiguration.selectors.</p>
*
* @typedef TutorialConfiguration
* @property {StepConfiguration[]} steps - An array of step configurations (see below).
* Note that this property can be passed as the configuration if the
* optional params below are not used.
* @property {integer} [zIndex=20] - Sets the base z-index value used by this tutorial
* @property {boolean} [shouldOverlay=true] - Setting to false will disable the
* overlay that normally appears over the page and behind the tooltips.
* @property {string} [overlayColor='rgba(255,255,255,0.7)'] - Overlay CSS color
* @property {Tutorial-onCompleteCallback} [onComplete] - Callback that is called
* once the tutorial has gone through all steps.
* @property {boolean} [useTransparentOverlayStrategy=false] - Setting to true will use an
* implementation that does not rely on cloning highlighted elements.
* Note: This value is ignored if a step contains multiple selectors.
* useTransparentOverlayStrategy is named as such because
* @property {boolean} [animated=false] - (TODO) Enables spotlight-like
* transitions between steps.
*/
/**
* @constructor
* @param {TutorialConfiguration} config - The configuration for this tutorial
* @param {string} [name] - Name of the tutorial
* @param {ChariotDelegate} [delegate] - An optional delegate that responds to
* lifecycle callbacks
*/
constructor(config, name, delegate) {
this.name = name;
this.delegate = delegate || {};
this.steps = [];
let steps, overlayConfig;
if (Object.prototype.toString.call(config) === '[object Array]') {
steps = config;
overlayConfig = {};
} else if (Object.prototype.toString.call(config) === '[object Object]') {
if (typeof config.steps !== 'object') {
throw new Error(`steps must be an array.\n${this}`);
return;
}
this.zIndex = config.zIndex;
this.useTransparentOverlayStrategy = config.useTransparentOverlayStrategy;
steps = config.steps;
overlayConfig = config;
}
this.overlay = new Overlay(overlayConfig);
steps.forEach((step, index) => {
this.steps.push(new Step(step, index, this, this.overlay, this.delegate));
});
this._prepared = false;
this._isActive = false;
this.navigationHandler = new NavigationHandler(this, this.delegate);
}
/**
* Indicates if this tutorial is currently active.
* return {boolean}
*/
isActive() {
return this._isActive;
}
/**
* Starts the tutorial and marks itself as inactive.
* @returns {Promise}
*/
start() {
if (this.zIndex !== null) {
Constant.reload({ overlayZIndex: this.zIndex });
}
if (this.navigationHandler) {
this.navigationHandler.setup();
}
if (this.steps.length === 0) {
throw new Error(`steps should not be empty.\n${this}`);
return;
}
this._isActive = true;
// render overlay first to avoid willBeingTutorial delay overlay showing up
this.overlay.render();
return Promise.resolve().then(() => {
if (this.delegate.willBeginTutorial) {
return this.delegate.willBeginTutorial(this);
}
}).then(() => {
this.currentStep = this.steps[0];
this.currentStep.render();
}).catch(() => {
this.tearDown();
});
}
/**
* Prepares each step of the tutorial, to speedup rendering.
* @returns {undefined}
*/
prepare() {
if (this._prepared) return;
this.steps.forEach(step => {
step.prepare();
this._prepared = true;
});
}
/**
* Advances to the next step in the tutorial, or ends tutorial if no more
* steps.
*
* @param {integer|Step} [step] - If step is an integer, advances to that
* step. If step is a Step instance, that step
* If no argument is passed in, the current step's index is incremented to
* determine the next step.
* @returns {undefined}
*/
next(step) {
let currentStepIndex = -1;
if (!step) {
currentStepIndex = this.steps.indexOf(this.currentStep);
if (currentStepIndex < 0) {
throw new Error('step not found');
return;
} else if (currentStepIndex === this.steps.length - 1) {
this.end();
return;
}
}
if (this.currentStep) {
this.currentStep.tearDown();
}
let nextStep;
if (step && typeof step === 'number') {
nextStep = this.steps[step];
} else if (step && typeof step === 'object') {
nextStep = step;
} else {
nextStep = this.steps[currentStepIndex + 1];
}
this.currentStep = nextStep;
nextStep.render();
}
/**
* Returns the one-indexed (read: human-friendly) step number.
*
* @param {Step} step - The step instance for which we want the index
* @returns {integer} stepNum - The onde-indexed step number
*/
stepNum(step) {
if (step === null) return null;
return this.steps.indexOf(step) + 1;
}
/**
* Tears down the internal overlay, each individual step, and the navigation
* handler
* Nulls out internal references.
* @returns {undefined}
*/
tearDown() {
if (this.navigationHandler) {
this.navigationHandler.tearDown();
this.navigationHandler = null;
}
this._prepared = false;
this.overlay.tearDown();
this.steps.forEach(step => {
step.tearDown();
});
this.currentStep = null;
}
/**
* Retrieves the Step object at index.
* @returns {Step} step
*/
getStep(index) {
return this.steps[index];
}
/**
* Ends the tutorial by tearing down all the steps (and associated tooltips,
* overlays).
* Also marks itself as inactive.
* @param {boolean} [forced=false] - Indicates whether tutorial was forced to
* end
* @returns {undefined}
*/
end(forced = false) {
// Note: Order matters.
this.tearDown();
return Promise.resolve().then(() => {
if (this.delegate.didFinishTutorial) {
return this.delegate.didFinishTutorial(this, forced);
}
}).then(() => {
this._isActive = false;
// Remove tutorial query parameter from URL
const match = window.location.href.match(/\?.*tutorial=([^&]*)/);
if (match && match.length) {
const newUrl = window.location.href.replace(/tutorial=([^&]*)/, '');
history.pushState({ path: newUrl }, '', newUrl)
}
});
}
toString() {
return `[Tutorial - active: ${this._isActive}, ` +
`useTransparentOverlayStrategy: ${this.useTransparentOverlayStrategy}, ` +
`steps: ${this.steps}, overlay: ${this.overlay}]`;
}
}
export default Tutorial;