Show:
/* Copyright © 2015-2016 David Valdman */

define(function(require, exports, module) {
    var ElementOutput = require('../core/ElementOutput');
    var dirtyQueue = require('../core/queues/dirtyQueue');

    var isTouchEnabled = "ontouchstart" in window;
    var usePrefix = !('transform' in document.documentElement.style);
    var isIOS = /iPad|iPhone|iPod/.test(navigator.platform);

    /**
     * Surface is a wrapper for a DOM element animated by Samsara.
     *  Samsara will commit opacity, size and CSS3 `transform` properties into the Surface.
     *  CSS classes, properties and DOM attributes can also be added and dynamically changed.
     *  Surfaces also act as sources for DOM events such as `click`.
     *
     * @example
     *
     *      var context = new Context()
     *
     *      var surface = new Surface({
     *          content : 'Hello world!',
     *          size : [true,100],
     *          opacity : .5,
     *          classes : ['myClass1', 'myClass2'],
     *          properties : {background : 'red'}
     *      });
     *
     *      context.add(surface);
     *
     *      context.mount(document.body);
     *
     *  @example
     *
     *      // same as above but create an image instead
     *      var surface = new Surface({
     *          tagName : 'img',
     *          attributes : {
     *              src : 'cat.jpg'
     *          },
     *          size : [100,100]
     *      });
     *
     * @class Surface
     * @namespace DOM
     * @constructor
     * @extends Core.ElementOutput
     * @param [options] {Object}                Options
     * @param [options.size] {Number[]}         Size (width, height) in pixels. These can also be `true` or `undefined`.
     * @param [options.classes] {String[]}      CSS classes
     * @param [options.properties] {Object}     Dictionary of CSS properties
     * @param [options.attributes] {Object}     Dictionary of HTML attributes
     * @param [options.content] Sstring}        InnerHTML content
     * @param [options.origin] {Number[]}       Origin (x,y), with values between 0 and 1
     * @param [options.margins] {Number[]}      Margins (x,y) in pixels
     * @param [options.proportions] {Number[]}  Proportions (x,y) with values between 0 and 1
     * @param [options.aspectRatio] {Number}    Aspect ratio
     * @param [options.opacity=1] {Number}      Opacity
     * @param [options.tagName="div"] {String}  HTML tagName
     * @param [options.enableScroll] {Boolean}  Allows a Surface to support native scroll behavior
     * @param [options.roundToPixel] {Boolean}  Prevents text-blurring if set to true, at the cost to jittery animation
     */
    function Surface(options) {
        this.properties = {};
        this.attributes = {};
        this.content = '';
        this.classList = [];

        this._contentDirty = true;
        this._dirtyClasses = [];
        this._classesDirty = true;
        this._stylesDirty = true;
        this._attributesDirty = true;
        this._dirty = false;
        this._cachedSize = null;
        this._allocator = null;

        if (options) {
            // default to DOM size for provided elements
            if (options.el && !options.size){
                this._contentDirty = false;
                options.size = [true, true];
            }

            ElementOutput.call(this, options.el);
            this.setOptions(options);
        }
        else ElementOutput.call(this);
    }

    Surface.prototype = Object.create(ElementOutput.prototype);
    Surface.prototype.constructor = Surface;
    Surface.prototype.elementType = 'div'; // Default tagName. Can be overridden in options.
    Surface.prototype.elementClass = 'samsara-surface';

    function _setDirty(){
        if (this._dirty || !this._currentTarget) return;

        dirtyQueue.push(function(){
            var target = this._currentTarget;

            if (!target) return;

            if (this._classesDirty) {
                _removeClasses.call(this, target);
                _applyClasses.call(this, target);
            }

            if (this._stylesDirty) _applyProperties.call(this, target);

            if (this._attributesDirty) _applyAttributes.call(this, target);

            if (this._contentDirty) this.deploy(target);

            this._dirty = false;
        }.bind(this))
    }

    function _applyClasses(target) {
        for (var i = 0; i < this.classList.length; i++)
            target.classList.add(this.classList[i]);
        this._classesDirty = false;
    }

    function _applyProperties(target) {
        for (var key in this.properties)
            target.style[key] = this.properties[key];
        this._stylesDirty = false;
    }

    function _applyAttributes(target) {
        for (var key in this.attributes)
            target.setAttribute(key, this.attributes[key]);
        this._attributesDirty = false;
    }

    function _removeClasses(target) {
        for (var i = 0; i < this._dirtyClasses.length; i++)
            target.classList.remove(this._dirtyClasses[i]);
        this._dirtyClasses = [];
    }

    function _removeProperties(target) {
        for (var key in this.properties)
            target.style[key] = '';
    }

    function _removeAttributes(target) {
        for (var key in this.attributes)
            target.removeAttribute(key);
    }

    function enableScroll(){
        this.addClass('samsara-scrollable');

        if (!isTouchEnabled) return;

        this.on('deploy', function(target){
            // Hack to prevent page scrolling for iOS when scroll starts at extremes
            if (isIOS) {
                target.addEventListener('touchstart', function () {
                    var top = target.scrollTop;
                    var height = target.offsetHeight;
                    var scrollHeight = target.scrollHeight;

                    if (top == 0)
                        target.scrollTop = 1;
                    else if (top + height == scrollHeight)
                        target.scrollTop = scrollHeight - height - 1;

                }, false);
            }

            // Prevent bubbling to capture phase of window's touchmove event which prevents default.
            target.addEventListener('touchmove', function(event){
                event.stopPropagation();
            }, false);
        });
    }
    
    /**
     * Setter for HTML attributes.
     *
     * @method setAttributes
     * @chainable
     * @param attributes {Object}   HTML Attributes
     */
    Surface.prototype.setAttributes = function setAttributes(attributes) {
        for (var key in attributes) {
            var value = attributes[key];
            if (value != undefined) this.attributes[key] = attributes[key];
        }
        this._attributesDirty = true;
        _setDirty.call(this);
        return this;
    };

    /**
     * Getter for HTML attributes.
     *
     * @method getAttributes
     * @return {Object}
     */
    Surface.prototype.getAttributes = function getAttributes() {
        return this.attributes;
    };

    /**
     * Setter for CSS properties.
     *  Note: properties are camelCased, not hyphenated.
     *
     * @method setProperties
     * @chainable
     * @param properties {Object}   CSS properties
     */
    Surface.prototype.setProperties = function setProperties(properties) {
        for (var key in properties)
            this.properties[key] = properties[key];
        this._stylesDirty = true;
        _setDirty.call(this);
        return this;
    };

    /**
     * Getter for CSS properties.
     *
     * @method getProperties
     * @return {Object}             Dictionary of this Surface's properties.
     */
    Surface.prototype.getProperties = function getProperties() {
        return this.properties;
    };

    /**
     * Add CSS class to the list of classes on this Surface.
     *
     * @method addClass
     * @chainable
     * @param className {String}    Class name
     */
    Surface.prototype.addClass = function addClass(className) {
        if (this.classList.indexOf(className) < 0) {
            this.classList.push(className);
            this._classesDirty = true;
            _setDirty.call(this);
        }
        return this;
    };

    /**
     * Remove CSS class from the list of classes on this Surface.
     *
     * @method removeClass
     * @param className {string}    Class name
     */
    Surface.prototype.removeClass = function removeClass(className) {
        var i = this.classList.indexOf(className);
        if (i >= 0) {
            this._dirtyClasses.push(this.classList.splice(i, 1)[0]);
            this._classesDirty = true;
            _setDirty.call(this);
        }
    };

    /**
     * Toggle CSS class for this Surface.
     *
     * @method toggleClass
     * @param  className {String}   Class name
     */
    Surface.prototype.toggleClass = function toggleClass(className) {
        var i = this.classList.indexOf(className);
        (i == -1)
            ? this.addClass(className)
            : this.removeClass(className);
    };

    /**
     * Reset classlist.
     *
     * @method setClasses
     * @chainable
     * @param classlist {String[]}  ClassList
     */
    Surface.prototype.setClasses = function setClasses(classList) {
        var removal = [];
        for (var i = 0; i < this.classList.length; i++) {
            if (classList.indexOf(this.classList[i]) < 0) removal.push(this.classList[i]);
        }
        for (i = 0; i < removal.length; i++) this.removeClass(removal[i]);
        // duplicates are already checked by addClass()
        for (i = 0; i < classList.length; i++) this.addClass(classList[i]);
        _setDirty.call(this);
        return this;
    };

    /**
     * Get array of CSS classes attached to this Surface.
     *
     * @method getClasslist
     * @return {String[]}
     */
    Surface.prototype.getClassList = function getClassList() {
        return this.classList;
    };

    /**
     * Set or overwrite innerHTML content of this Surface.
     *
     * @method setContent
     * @chainable
     * @param content {String|DocumentFragment} HTML content
     */
    Surface.prototype.setContent = function setContent(content) {
        if (this.content !== content) {
            this.content = content;
            this._contentDirty = true;
            _setDirty.call(this);
        }
        return this;
    };

    /**
     * Return innerHTML content of this Surface.
     *
     * @method getContent
     * @return {String}
     */
    Surface.prototype.getContent = function getContent() {
        return this.content;
    };

    /**
     * Set options for this surface
     *
     * @method setOptions
     * @param options {Object} Overrides for default options. See constructor.
     */
    Surface.prototype.setOptions = function setOptions(options) {
        if (options.tagName !== undefined) this.elementType = options.tagName;
        if (options.opacity !== undefined) this.setOpacity(options.opacity);
        if (options.size !== undefined) this.setSize(options.size);
        if (options.origin !== undefined) this.setOrigin(options.origin);
        if (options.proportions !== undefined) this.setProportions(options.proportions);
        if (options.margins !== undefined) this.setMargins(options.margins);
        if (options.classes !== undefined) this.setClasses(options.classes);
        if (options.properties !== undefined) this.setProperties(options.properties);
        if (options.attributes !== undefined) this.setAttributes(options.attributes);
        if (options.content !== undefined) this.setContent(options.content);
        if (options.aspectRatio !== undefined) this.setAspectRatio(options.aspectRatio);
        if (options.enableScroll) enableScroll.call(this);
        if (options.roundToPixel) this.roundToPixel = options.roundToPixel;
    };

    /**
     * Allocates the element-type associated with the Surface, adds its given
     *  element classes, and prepares it for future committing.
     *
     *  This method is called upon the first `start` or `resize`
     *  event the Surface gets.
     *
     * @private
     * @method setup
     * @param allocator {ElementAllocator} Allocator
     */
    Surface.prototype.setup = function setup(allocator) {
        this._allocator = allocator;

        // create element of specific type
        var target = allocator.allocate(this.elementType);

        // add any element classes
        if (this.elementClass) {
            if (this.elementClass instanceof Array)
                for (var i = 0; i < this.elementClass.length; i++)
                    this.addClass(this.elementClass[i]);
            else this.addClass(this.elementClass);
        }

        // set the currentTarget and any bound listeners
        this.attach(target);

        _applyClasses.call(this, target);
        _applyProperties.call(this, target);
        _applyAttributes.call(this, target);
        this.deploy(target);
    };

    /**
     * Remove all Samsara-relevant data from the Surface.
     *
     * @private
     * @method remove
     * @param allocator {ElementAllocator} Allocator
     */
    Surface.prototype.remove = function remove(allocator) {
        //TODO: don't reference allocator in state
        allocator = allocator || this._allocator;
        var target = this._currentTarget;

        // cache the target's contents for later deployment
        this.recall(target);

        // hide the element
        target.style.display = 'none';
        target.style.opacity = '';
        target.style.width = '';
        target.style.height = '';

        if (usePrefix){
            target.style.webkitTransform = 'scale3d(0.0001,0.0001,0.0001)';
            target.style.webkitTransformOrigin = '';
        }
        else {
            target.style.transform = 'scale3d(0.0001,0.0001,0.0001)';
            target.style.transformOrigin = '';
        }

        for (var i = 0; i < this.classList.length; i++)
            this.removeClass(this.classList[i]);

        // clear all styles, classes and attributes
        _removeProperties.call(this, target);
        _removeAttributes.call(this, target);
        _removeClasses.call(this, target);

        // garbage collect current target and remove bound event listeners
        this.detach();

        // store allocated node in cache for recycling
        allocator.deallocate(target);
    };

    /**
     * Insert the Surface's content into the currentTarget.
     *
     * @private
     * @method deploy
     * @param target {Node} DOM element to set content into
     */
    Surface.prototype.deploy = function deploy(target) {
        //TODO: make sure target.tagName is of correct type! Tag pools must be implemented.
        if (!target) return;
        var content = this.getContent();
        if (content instanceof Node) {
            while (target.hasChildNodes()) target.removeChild(target.firstChild);
            target.appendChild(content);
        }
        else target.innerHTML = content;

        this._contentDirty = false;
        this._eventOutput.emit('deploy', target);
    };

    /**
     * Cache the content of the Surface in a document fragment for future deployment.
     *
     * @private
     * @method recall
     * @param target {Node}
     */
    Surface.prototype.recall = function recall(target) {
        this._eventOutput.emit('recall');
        var df = document.createDocumentFragment();
        while (target.hasChildNodes()) df.appendChild(target.firstChild);
        this.setContent(df);
    };

    /**
     * Getter for size.
     *
     * @method getSize
     * @return {Number[]}
     */
    Surface.prototype.getSize = function getSize() {
        // TODO: remove cachedSize
        return this._cachedSpec.size || this._cachedSize;
    };

    /**
     * Setter for size.
     *
     * @method setSize
     * @param size {Number[]|Stream} Size as [width, height] in pixels, or a stream.
     */
    Surface.prototype.setSize = function setSize(size) {
        this._cachedSize = size;
        this._sizeNode.set({size : size});
        _setDirty.call(this);
    };

    /**
     * Setter for proportions.
     *
     * @method setProportions
     * @param proportions {Number[]|Stream} Proportions as [x,y], or a stream.
     */
    Surface.prototype.setProportions = function setProportions(proportions) {
        this._sizeNode.set({proportions : proportions});
        _setDirty.call(this);
    };

    /**
     * Setter for margins.
     *
     * @method setMargins
     * @param margins {Number[]|Stream} Margins as [width, height] in pixels, or a stream.
     */
    Surface.prototype.setMargins = function setMargins(margins) {
        this._sizeNode.set({margins : margins});
        _setDirty.call(this);
    };

    /**
     * Setter for aspect ratio. If only one of width or height is specified,
     *  the aspect ratio will replace the unspecified dimension by scaling
     *  the specified dimension by the value provided.
     *
     * @method setAspectRatio
     * @param aspectRatio {Number|Stream} Aspect ratio.
     */
    Surface.prototype.setAspectRatio = function setAspectRatio(aspectRatio) {
        this._sizeNode.set({aspectRatio : aspectRatio});
        _setDirty.call(this);
    };

    /**
     * Setter for origin.
     *
     * @method setOrigin
     * @param origin {Number[]|Stream} Origin as [x,y], or a stream.
     */
    Surface.prototype.setOrigin = function setOrigin(origin){
        this._layoutNode.set({origin : origin});
        this._originDirty = true;
        _setDirty.call(this);
    };

    /**
     * Setter for opacity.
     *
     * @method setOpacity
     * @param opacity {Number} Opacity
     */
    Surface.prototype.setOpacity = function setOpacity(opacity){
        this._layoutNode.set({opacity : opacity});
        this._opacityDirty = true;
        _setDirty.call(this);
    };

    module.exports = Surface;
});