import Step from './step';
import Overlay from './overlay';
const Promise = require('es6-promise').Promise;
import Constant from './constants';
const DEFAULT_SCROLL_ANIMATION_DURATION = 500;
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 <code>margin</code> 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] - <p>
* Setting to true will use an implementation that does not rely on
* cloning highlighted elements.<br/>
* Note: This value is ignored if multiple selectors are
* specified in <code>StepConfiguration.selectors</code>.</p>
* <p><code>useTransparentOverlayStrategy</code> focuses on an element by
* resizing a transparent overlay to match its dimensions and changes the
* borders to be colored to obscure the main UI.<p>
<h4>Strategy Details</h4>
<p>
By default, a tutorial is displayed with a semi-transparent overlay
that hides background content and highlights the selected element(s) for the
current step of the tutorial.
</p>
This is achieved by one of two exclusive strategies:
<ol>
<li>
An overlay div with a semi-transparent border but with a transparent center
equal in size to the selected element.<br/>
Example: A 50x50 div is the selected element, so the overlay's transparent center
is 50x50, and its semi-transparent border fills the rest of the viewport.
</li>
<li>
A completely semi-transparent overlay fills the entire viewport, and the
selected element(s) is cloned and placed on top of this overlay, using
<code>z-index</code>.
</li>
</ol>
<p>Both strategies have pros & cons.</p>
1. Clone strategy (default)
<p>
<strong>Pros:</strong> It will correctly render the entire element in question,
regardless of shape or size.
</p>
<p>
<strong>Cons:</strong> Slow because of the deep-cloning involved with CSS styling. The more
children elements that exist, the slower each step will take to render.
(This can be improved over time by pre-caching the next step in advance.)
There are also edge cases where Firefox will not clone the
CSS `margin` attribute of children elements.
<br/>
In those cases, the callbacks <code>Step.beforeCallback</code> and
<code>Step.afterCallback</code> can be used to properly restore the margin.
</p>
2. Background overlay with transparent center and semi-transparent border
<p>
<strong>Pros:</strong>: More performant than the clone strategy because styles are not being cloned.
</p>
<p>
<strong>Cons:</strong> When an element is not rectangular in shape, or
when it has <code>:before</code> or <code>:after</code> pseudo-selectors
that insert new DOM elements that protrude out of the main element,
the transparent center will either reveal or occlude sections of
the element.
</p>
Note: The clone strategy is always chosen if multiple selectors are
specified in <code>StepConfiguration.selectors</code>.
* @property {boolean} [animateTooltips=true] - Enables tooltip bouncing at the
* beginning and end of each step.
* @property {boolean} [animateScrolling=true] -
* <p>If the next tooltip is not completely within the client bounds, this
* property animates the scrolling of the viewport until the next tooltip
* is centered.</p>
* <p>If false, the viewport is not scrolled.</p
* @property {integer} [scrollAnimationDuration=500] - Specifies the duration
* of the scroll animation above, in milliseconds.
* Ignored if <code>animateScrolling</code> is false.
*/
/**
* @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 = [];
const configType = Object.prototype.toString.call(config);
let stepConfigs, overlayConfig;
if (configType === '[object Array]') {
stepConfigs = config;
overlayConfig = {};
this.animateTooltips = true;
this.animateScrolling = true;
this.scrollAnimationDuration = DEFAULT_SCROLL_ANIMATION_DURATION;
} else if (configType === '[object Object]') {
if (!Array.isArray(config.steps)) {
throw new Error(`steps must be an array.\n${this}`);
}
this.zIndex = config.zIndex;
this.useTransparentOverlayStrategy = config.useTransparentOverlayStrategy;
this.animateTooltips = config.animateTooltips === undefined ? true : config.animateTooltips;
this.animateScrolling = config.animateScrolling === undefined ? true : config.animateScrolling;
this.scrollAnimationDuration = config.scrollAnimationDuration || DEFAULT_SCROLL_ANIMATION_DURATION;
stepConfigs = config.steps;
overlayConfig = config;
} else {
throw new Error('config must be an object or array');
}
this.overlay = new Overlay(overlayConfig);
stepConfigs.forEach((stepConfig, index) => {
this.steps.push(new Step(stepConfig, index, this, this.overlay, this.delegate));
});
this._prepared = false;
this._isActive = false;
}
/**
* Indicates if this tutorial is currently active.
* return {boolean}
*/
isActive() {
return this._isActive;
}
/**
* Starts the tutorial and marks itself as active.
* @returns {Promise}
*/
start() {
if (this.zIndex !== null) {
Constant.reload({ overlayZIndex: this.zIndex });
}
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');
} else if (currentStepIndex === this.steps.length - 1) {
this.end();
return;
}
}
if (this.currentStep) {
this.currentStep.tearDown();
}
let nextStep;
if (step) {
if (typeof step === 'number') {
if (step < 0 || step >= this.steps.length) {
throw new Error(`step is outside bounds of steps array (length: ${this.steps.length})`);
}
nextStep = this.steps[step];
} else if (Object.prototype.toString.call(step) === '[object Object]') {
nextStep = step;
} else {
throw new Error('step arg must be number or object');
}
} else {
nextStep = this.steps[currentStepIndex + 1];
}
this.currentStep = nextStep;
nextStep.render();
}
/**
* Returns the one-indexed (human-friendly) step number.
*
* @param {Step} step - The step instance for which we want the index
* @returns {integer} stepNum - The one-indexed step number
*/
stepNum(step) {
if (step === null) return null;
return this.steps.indexOf(step) + 1;
}
/**
* Tears down the internal overlay and tears down each individual step
* Nulls out internal references.
* @returns {undefined}
*/
tearDown() {
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;
});
}
toString() {
return `[Tutorial - active: ${this._isActive}, ` +
`useTransparentOverlayStrategy: ${this.useTransparentOverlayStrategy}, ` +
`steps: ${this.steps}, overlay: ${this.overlay}]`;
}
}
export default Tutorial;