Source: step.js

import debounce from 'lodash.debounce';
import Tooltip from './tooltip';
import Constants from './constants';
import Style from './style';

let MAX_ATTEMPTS = 100;
let DOM_QUERY_DELAY = 100;

let Promise = require('es6-promise').Promise;

class Step {

  /** The step configuration is where you specify which elements of the page
   * will be cloned and placed over the overlay. These elements are the
   * what appear as highlighted to the user.
   *
   * @typedef StepConfiguration
   * @property {TooltipConfiguration} tooltip - Tooltip configuration.
   * @property {Object.<string, string>|string[]|string} [selectors] -
   *  Object with arbitrarily-named keys and CSS selector values.
   *  These keys can then be referenced from TooltipConfiguration.anchorElement.
   *  Or, an array of selector strings if named keys are not required.
   *  Or, a string if only one selector is required.
   *  Notes: Specifying a selector that targets another specified selector
   *  will result in unpredictable behavior.
   *  Specifying multiple selectors will effectively cause
   *  Tutorial.useTransparentOverlayStrategy == false.
   */

  /**
   * @constructor
   * @param {StepConfiguration} config - The configuration for this step
   * @param {integer} index - The index of this step within the current tutorial
   * @param {Tutorial} tutorial - The Tutorial object corresponding to this Step
   * @param {Overlay} overlay - The Overlay object displayed along with this
   *  Step
   * @param {ChariotDelegate} [delegate] - An optional delegate that responds to
   *  lifecycle callbacks
   */
  constructor(config = {}, index, tutorial, overlay, delegate) {
    this.tutorial = tutorial;
    this.index = index;
    this.overlay = overlay;
    this.delegate = delegate || {};
    if (!config.selectors || !Object.keys(config.selectors).length) {
      throw new Error('selectors must be present in Step configuration\n' +
        this);
    } else if (Object.prototype.toString.call(config.selectors) === '[object Object]') {
      this.selectors = config.selectors;
    } else if (Object.prototype.toString.call(config.selectors) === '[object Array]') {
      const selectorsMap = {};
      config.selectors.forEach((val, idx) => {
        selectorsMap[idx] = val;
      });
      this.selectors = selectorsMap;
    } else if (typeof config.selectors === 'string') {
      this.selectors = { 0: config.selectors };
    } else {
      throw new Error('selectors must be an object, array, or string');
    }

    this._resizeTimeout = null;

    this._elementMap = {};
    for (let selectorName in this.selectors) {
      this._elementMap[selectorName] = {};
    }

    this.tooltip = new Tooltip(config.tooltip, this, tutorial);
  }

  render() {
    Promise.resolve().then(() => {
      if (this.delegate.willBeginStep) {
        return this.delegate.willBeginStep(
          this, this.index, this.tutorial);
      }
    }).then(() => {
      if (this.delegate.willShowOverlay) {
        return this.delegate.willShowOverlay(
          this.overlay, this.index, this.tutorial);
      }
    }).then(() => {
      // Show a temporary background overlay while we wait for elements
      this.overlay.showBackgroundOverlay();
      return this._waitForElements();
    }).then(() => {
      // Render the overlay
      if (this.overlay.isTransparentOverlayStrategy() &&
          Object.keys(this.selectors).length === 1) {
        this._singleTransparentOverlayStrategy();
      } else {
        this._clonedElementStrategy();
      }
    }).then(() => {
      if (this.delegate.didShowOverlay) {
        return this.delegate.didShowOverlay(
          this.overlay, this.index, this.tutorial);
      }
    }).then(() => {
      if (this.delegate.willRenderTooltip) {
        return this.delegate.willRenderTooltip(
          this.tooltip, this.index, this.tutorial);
      }
    }).then(() => {
      this._renderTooltip();
      if (this.delegate.didRenderTooltip) {
        return this.delegate.didRenderTooltip(
          this.tooltip, this.index, this.tutorial);
      }
    }).then(() => {
      // Resize the overlay in case the tooltip extended the width/height of DOM
      this.overlay.resize();

      // Setup resize handler
      this._resizeHandler = debounce(() => {
        for (let selectorName in this.selectors) {
          let elementInfo = this._elementMap[selectorName];
          if (elementInfo.clone) {
            let $element = elementInfo.element;
            let $clone = elementInfo.clone;
            Style.clearCachedStylesForElement($element);
            this._applyComputedStyles($clone, $element);
            this._positionClone($clone, $element);
          }
        }
        this.tooltip.reposition();
        this.overlay.resize();
      }, 50);
      $(window).on('resize', this._resizeHandler);
    }).catch(error => {
      console.log(error);
      this.tutorial.tearDown();
    });
  }

  next() {
    Promise.resolve().then(() => {
      if (this.delegate.didFinishStep) {
        return this.delegate.didFinishStep(
          this, this.index, this.tutorial);
      }
    }).then(() => {
      this.tutorial.next();
    }).catch(error => {
      console.log(error);
      this.tutorial.next();
    });
  }

  getClonedElement(selectorName) {
    let elementInfo = this._elementMap[selectorName];
    if (!elementInfo) return;
    return elementInfo.clone;
  }

  tearDown() {
    let $window = $(window);
    for (let selectorName in this.selectors) {
      let selector = this.selectors[selectorName]
      // Remove computed styles
      Style.clearCachedStylesForElement($(selector));
      let elementInfo = this._elementMap[selectorName];
      if (elementInfo.clone) {
        // Remove cloned elements
        elementInfo.clone.remove();
      }
    }
    this.tooltip.tearDown();

    $window.off('resize', this._resizeHandler);
  }

  prepare() {
    // FIX: This method currently always prepares for the clone strategy,
    // regardless of the value of useTransparentOverlayStrategy.
    // Perhaps add a check or rename this method, once the coupling to
    // this.tutorial.prepare() is removed
    for (let selectorName in this.selectors) {
      let selector = this.selectors[selectorName]
      this._computeStyles($(selector));
    }
  }

  toString() {
    return `[Step - index: ${this.index}, ` +
      `selectors: ${JSON.stringify(this.selectors)}]`;
  }

  //// PRIVATE

  _singleTransparentOverlayStrategy() {
    // Only use an overlay
    let selectorName = Object.keys(this.selectors)[0];
    let $element =  this._elementMap[selectorName].element;
    this.overlay.focusOnElement($element);
  }

  _clonedElementStrategy() {
    // Clone elements if multiple selectors
    this._cloneElements(this.selectors);
    this.overlay.showTransparentOverlay();
  }

  _renderTooltip() {
    this.tooltip.render();
  }

  _waitForElements() {
    let promises = [];
    for (let selectorName in this.selectors) {
      let promise = new Promise((resolve, reject) => {
        this._waitForElement(selectorName, 0, resolve, reject);
      });
      promises.push(promise);
    }

    return Promise.all(promises);
  }

  _waitForElement(selectorName, numAttempts, resolve, reject) {
    let selector = this.selectors[selectorName];
    let element = $(selector);
    if (element.length == 0) {
      ++numAttempts;
      if (numAttempts == MAX_ATTEMPTS) {
        reject(`Selector not found: ${selector}`);
      } else {
        window.setTimeout(() => {
          this._waitForElement(selectorName, numAttempts, resolve, reject);
        }, DOM_QUERY_DELAY);
      }
    } else {
      this._elementMap[selectorName].element = element;
      resolve();

      // TODO: fire event when element is ready. Tutorial will listen and call
      // prepare() on all steps
    }
  }

  _computeStyles($element) {
    Style.getComputedStylesFor($element[0]);
    $element.children().toArray().forEach(child => {
      this._computeStyles($(child));
    });
  }

  _cloneElements(selectors) {
    if (this.overlay.isVisible()) return;

    setTimeout(() => {
      this.tutorial.prepare();
    }, 0);
    for (let selectorName in selectors) {
      let clone = this._cloneElement(selectorName);
      this._elementMap[selectorName].clone = clone;
    }
  }

  _cloneElement(selectorName) {
    let $element = this._elementMap[selectorName].element;
    if ($element.length == 0) { return null; }

    let $clone = $element.clone();
    $('body').append($clone);
    this._applyComputedStyles($clone, $element);
    this._positionClone($clone, $element);

    return $clone;
  }

  _applyComputedStyles($clone, $element) {
    if (!$element.is(":visible")) {
      return;
    }
    $clone.addClass('chariot-clone');
    Style.cloneStyles($element, $clone);
    let clonedChildren = $clone.children().toArray();
    $element.children().toArray().forEach((child, index) => {
      this._applyComputedStyles($(clonedChildren[index]), $(child));
    });
  }

  _positionClone($clone, $element) {
    $clone.css({
      'z-index': Constants.CLONE_Z_INDEX,
      position: 'absolute'
    });
    $clone.offset($element.offset());
  }
}

export default Step;