Source: frame.js

var _ = require("lodash");
var binder = require("./events").binder;
var hasEmitter = require("./events").hasEmitter;
var DecksEvent = require("./events").DecksEvent;
var rect = require("./utils").rect;
var dom = require("./ui").dom;
var validate = require("./utils/validate");

/**
 * Represents the Viewport Frame.  The Frame is essentially a DOM element which acts as the
 * visible portion of the decks system.  The Frame is always set to position relative, and
 * overflow hidden.  The Frame contains a Canvas (element), which can move around within the
 * Frame element.  The Frame crops the content of the Canvas at the Frame edges.
 *
 * @class
 * @mixes binder
 * @mixes hasEmitter
 * @param {!Object} options Frame options
 * @param {!HTMLElement} options.element Frame container element
 * @param {?(Canvas|Object)} options.canvas Frame canvas instance or options
 */
function Frame(options) {
  if (!(this instanceof Frame)) {
    return new Frame(options);
  }

  options = _.merge({}, this.defaultOptions, options);

  this.frameId = _.uniqueId();
  this.position = options.position;
  this.overflow = options.overflow;
  this.watchWindowResize = options.watchWindowResize;
  this.watchWindowScroll = options.watchWindowScroll;
  this.debouncedOnWindowResize = _.debounce(this.onWindowResize, options.debouncedWindowResizeWait);
  this.debouncedOnWindowScroll = _.debounce(this.onWindowScroll, options.debouncedWindowScrollWait);

  this.setConfig(options.config);
  this.setEmitter(options.emitter);
  this.setElement(options.element);

  this.bind();

  this.emit(DecksEvent("frame:ready", this));
}

_.extend(Frame.prototype, binder, hasEmitter, /** @lends Frame.prototype */ {
  defaultOptions: {
    position: "relative",
    overflow: "auto",
    watchWindowResize: true,
    watchWindowScroll: true,
    debouncedWindowResizeWait: 200,
    debouncedWindowScrollWait: 200
  },

  /**
   * Gets the {@link Emitter} events map.
   *
   * @return {Object}
   */
  getEmitterEvents: function() {
    return {
      "canvas:element:set": "onCanvasElementSet",
      "deck:resize": "onDeckResize"
    };
  },

  /**
   * Gets the window events map.
   *
   * @return {Object}
   */
  getWindowEvents: function() {
    var map = {};
    if (this.watchWindowResize) {
      map.resize = "debouncedOnWindowResize";
    }
    if (this.watchWindowScroll) {
      map.scroll = "debouncedOnWindowScroll";
    }
    return map;
  },

  /**
   * Binds the {@link Emitter} and window events.
   *
   * @return {undefined}
   */
  bind: function bind() {
    this.bindEvents(this.emitter, this.getEmitterEvents());
    this.bindEvents(window, this.getWindowEvents());
  },

  /**
   * Unbinds the {@link Emitter} and window events.
   *
   * @return {undefined}
   */
  unbind: function unbind() {
    this.unbindEvents(this.emitter, this.getEmitterEvents());
    this.unbindEvents(window, this.getWindowEvents());
  },

  /**
   * Destroys the {@link Frame}
   *
   * @return {undefined}
   */
  destroy: function() {
    this.unbind();
  },

  /**
   * Sets the config object.
   *
   * @param config
   * @return {undefined}
   */
  setConfig: function setConfig(config) {
    validate(config, "config", { isPlainObject: true, isNotSet: this.config });

    this.config = config;
  },

  /**
   * Sets the Frame's DOM element (container)
   *
   * @param {HTMLElement} element the Frame's main container element
   * @param {Object} options frame options
   * @return {undefined}
   */
  setElement: function setElement(element) {
    validate(element, "element", { isElement: true, isNotSet: this.element });

    this.element = element;

    if (!element.id) {
      this.element.id = this.config.frameClassName + "-" + this.frameId;
    }

    dom.addClass(this.element, this.config.frameClassName);

    // Frame must be positioned (absolute, relative or fixed), so that the Canvas can be positioned within it
    if (!dom.isPositioned(this.element)) {
      dom.setStyle(this.element, "position", this.position);
    }

    dom.setStyle(this.element, "overflow", this.overflow);

    this.emit(DecksEvent("frame:element:set", this, element));

    this.setBounds();
  },

  /**
   * Sets the frame size parameters
   *
   * @param options
   * @return {undefined}
   */
  setBounds: function setBounds() {
    var bounds = rect.normalize(this.element);

    // Ignore empty or unchanging bounds
    if (rect.isEmpty(bounds) || rect.isEqual(this.bounds, bounds)) {
      return;
    }

    this.emit(DecksEvent("frame:bounds:setting", this, { oldBounds: this.bounds, newBounds: bounds }));

    this.bounds = bounds;

    this.emit(DecksEvent("frame:bounds:set", this, this.bounds));
  },

  /**
   * Indicates if the given element is currently visible within the
   * Frame's container element.  This might be a combination of the element's
   * bounding rect being inside the Frame element, and stacking like z-index.
   *
   * @param {HTMLElement} element element to check for visibility
   * @return {boolean} whether the element is visible in the Frame
   */
  isElementVisible: function isElementVisible(element) {
    validate(element, "element", { isElement: true });

    return rect.intersects(this.element, element);
  },

  /**
   * Called when the {@link Canvas} element has been set.
   *
   * This appends the {@link Canvas} element in the {@link Frame} element, and also
   * initializes the {@link Canvas} with the {@link Frame} and {@link Frame} bounds.
   *
   * @param e
   * @return {undefined}
   */
  onCanvasElementSet: function onCanvasElementSet(e) {
    var canvas = e.sender;
    var canvasElement = e.data;

    // Add the Canvas element to the Frame element
    dom.empty(this.element);
    dom.append(this.element, canvasElement);

    // Set the initial canvas bounds to match the frame
    // TODO: there might be a better way to do this - the problem is that the Frame bounds are
    // set and an event is emitted before the Canvas has been instantiated, and there is somewhat
    // of a circular dependency between Frame and Canvas.
    canvas.setFrame(this);
    canvas.setFrameBounds(this.bounds);
  },

  /**
   * Called when a deck:resize event is received.  This event is used by the caller
   * to request that the {@link Frame} re-calculate it's bounds.  If the {@link Frame}
   * bounds have changed, it will usually trigger a render cycle in the {@link Viewport}.
   *
   * @return {undefined}
   */
  onDeckResize: function onDeckResize() {
    this.setBounds();
  },

  /**
   * Called on window resize event.  Causes the {@link Frame} to re-calculate its bounds,
   * which might result in a render cycle in the {@link Viewport}.
   *
   * @return {undefined}
   */
  onWindowResize: function onWindowResize() {
    this.setBounds();
  },

  /**
   * Called on window scroll event.  Causes the {@link Frame} to re-calculate its bounds,
   * which might result in a render cycle in the {@link Viewport}.
   *
   * @return {undefined}
   */
  onWindowScroll: function onWindowScroll() {
    this.setBounds();
  }
});

module.exports = Frame;