Show:
/* Copyright © 2015-2016 David Valdman */
// TODO: Enable CSS properties on Context
define(function(require, exports, module) {
    var Engine = require('../core/Engine');
    var RootNode = require('../core/nodes/RootNode');
    var Transform = require('../core/Transform');
    var ElementAllocator = require('../core/ElementAllocator');
    var Transitionable = require('../core/Transitionable');
    var OptionsManager = require('../core/OptionsManager');
    var SimpleStream = require('../streams/SimpleStream');
    var EventHandler = require('../events/EventHandler');
    var preTickQueue = require('../core/queues/preTickQueue');
    var dirtyQueue = require('../core/queues/dirtyQueue');

    var elementType = 'div';
    var rafStarted = false;
    var isMobile = /mobi/i.test(navigator.userAgent);
    var orientation = Number.NaN;
    var windowWidth = Number.NaN;
    var windowHeight = Number.NaN;

    var layoutSpec = {
        transform : Transform.identity,
        opacity : 1,
        origin : null,
        align : null,
        nextSizeTransform : Transform.identity
    };

    /**
     * A Context defines a top-level DOM element inside which other nodes (like Surfaces) are rendered.
     *
     *  The CSS class `samsara-context` is applied, which provides the minimal CSS necessary
     *  to create a performant 3D context (specifically `preserve-3d`).
     *
     *  The Context must be mounted to a DOM node via the `mount` method. If no node is specified
     *  it is mounted to `document.body`.
     *
     *  @example
     *
     *      var context = Context();
     *
     *      var surface = new Surface({
     *          size : [100,100],
     *          properties : {background : 'red'}
     *      });
     *
     *      context.add(surface);
     *      context.mount(document.body)
     *
     * @class Context
     * @constructor
     * @namespace DOM
     * @uses Core.RootNode
     *
     * @param [options] {Object}                        Options
     * @param [options.enableScroll=false] {Boolean}    Allow scrolling on mobile devices
     */
    function Context(options) {
        this.options = OptionsManager.setOptions(this, options, Context.DEFAULT_OPTIONS);
        this._node = new RootNode();

        this._size = new SimpleStream();
        this._layout = new SimpleStream();

        this.size = this._size.map(function(){
            var size = [this.container.clientWidth, this.container.clientHeight];
            this.emit('resize', size);
            return size;
        }.bind(this));

        this._node._size.subscribe(this.size);
        this._node._layout.subscribe(this._layout);

        this._perspective = new Transitionable();
        this._perspectiveOrigin = new Transitionable();

        this._perspective.on('update', function(perspective){
            if (!this.container) return;
            setPerspective(this.container, perspective);
        }.bind(this));

        this._perspective.on('end', function(perspective){
            if (!this.container) return;
            setPerspective(this.container, perspective);
        }.bind(this));

        this._perspectiveOrigin.on('update', function(origin) {
            if (!this.container) return;
            setPerspectiveOrigin(this.container, origin);
        }.bind(this));

        this._perspectiveOrigin.on('end', function(origin) {
            if (!this.container) return;
            setPerspectiveOrigin(this.container, origin);
        }.bind(this));

        this._eventOutput = new EventHandler();
        this._eventForwarder = function _eventForwarder(event) {
            this._eventOutput.emit(event.type, event);
        }.bind(this);

        // Prevents dragging of entire page
        if (this.options.enableScroll === false){
            this.on('deploy', function(target) {
                target.addEventListener('touchmove', function(event) {
                    event.preventDefault();
                }, false);
            });
        }
    }

    Context.prototype.elementClass = 'samsara-context';

    Context.DEFAULT_OPTIONS = {
        enableScroll : false
    };

    /**
     * Extends the render tree beginning with the Context's RootNode with a new node.
     *  Delegates to RootNode's `add` method.
     *
     * @method add
     *
     * @param {Object}          Renderable
     * @return {RenderTreeNode} Wrapped node
     */
    Context.prototype.add = function add() {
        return RootNode.prototype.add.apply(this._node, arguments);
    };

    /**
     * Get current perspective of this Context in pixels.
     *
     * @method getPerspective
     * @return {Number} Perspective in pixels
     */
    Context.prototype.getPerspective = function getPerspective() {
        return this._perspective.get();
    };

    /**
     * Set current perspective of the `context` in pixels.
     *
     * @method setPerspective
     * @param perspective {Number}  Perspective in pixels
     * @param [transition] {Object} Transition definition
     * @param [callback] {Function} Callback executed on completion of transition
     */
    Context.prototype.setPerspective = function setPerspective(perspective, transition, callback) {
        this._perspective.set(perspective, transition, callback);
    };

    /**
     * Set current perspective of the `context` in pixels.
     *
     * @method setPerspective
     * @param perspective {Number}  Perspective in pixels
     * @param [transition] {Object} Transition definition
     * @param [callback] {Function} Callback executed on completion of transition
     */
    Context.prototype.setPerspectiveOrigin = function setPerspectiveOrigin(origin, transition, callback) {
        this._perspectiveOrigin.set(origin, transition, callback);
    };

    /**
     * Allocate contents of the `context` to a DOM node.
     *
     * @method mount
     * @param node {Node}  DOM element
     */
    Context.prototype.mount = function mount(node, resizeListenFlag){
        this.container = node || document.createElement(elementType);
        this.container.classList.add(this.elementClass);

        var allocator = new ElementAllocator(this.container);
        this._node.setAllocator(allocator);

        this.emit('deploy', this.container);

        if (!node)
            document.body.appendChild(this.container);

        if (!resizeListenFlag)
            window.addEventListener('resize', handleResize.bind(this), false);

        preTickQueue.push(function (){
            if (!resizeListenFlag) handleResize.call(this);
            this._layout.trigger('start', layoutSpec);
            dirtyQueue.push(function(){
                this._layout.trigger('end', layoutSpec);
            }.bind(this));
        }.bind(this));

        if (!rafStarted) {
            rafStarted = true;
            Engine.start();
        }
    };

    /**
     * Adds a handler to the `type` channel which will be executed on `emit`.
     *  These events should be DOM events that occur on the DOM node the
     *  context has been mounted to.
     *
     * @method on
     * @param type {String}         Channel name
     * @param handler {Function}    Callback
     */
    Context.prototype.on = function on(type, handler){
        if (this.container)
            this.container.addEventListener(type, this._eventForwarder);
        else {
            this._eventOutput.on('deploy', function(target){
                target.addEventListener(type, this._eventForwarder);
            }.bind(this));
        }
        EventHandler.prototype.on.apply(this._eventOutput, arguments);
    };

    /**
     * Removes the `handler` from the `type`.
     *  Undoes the work of `on`.
     *
     * @method on
     * @param type {String}         Channel name
     * @param handler {Function}    Callback
     */
    Context.prototype.off = function off(type, handler) {
        EventHandler.prototype.off.apply(this._eventOutput, arguments);
    };

    /**
     * Used internally when context is subscribed to.
     *
     * @method emit
     * @private
     * @param type {String}     Channel name
     * @param data {Object}     Payload
     */
    Context.prototype.emit = function emit(type, payload) {
        EventHandler.prototype.emit.apply(this._eventOutput, arguments);
    };

    var usePrefix = !('perspective' in document.documentElement.style);

    var setPerspective = usePrefix
        ? function setPerspective(element, perspective) {
            element.style.webkitPerspective = perspective ? (perspective | 0) + 'px' : '0px';
        }
        : function setPerspective(element, perspective) {
            element.style.perspective = perspective ? (perspective | 0) + 'px' : '0px';
        };

    function _formatCSSOrigin(origin) {
        return (100 * origin[0]) + '% ' + (100 * origin[1]) + '%';
    }

    var setPerspectiveOrigin = usePrefix
        ? function setPerspectiveOrigin(element, origin) {
            element.style.webkitPerspectiveOrigin = origin ? _formatCSSOrigin(origin) : '50% 50%';
        }
        : function setPerspectiveOrigin(element, origin) {
            element.style.perspectiveOrigin = origin ? _formatCSSOrigin(origin) : '50% 50%';
        };

    function handleResize() {
        var newHeight = window.innerHeight;
        var newWidth = window.innerWidth;

        if (isMobile){
            var newOrientation = newHeight > newWidth;
            if (orientation === newOrientation) return false;
            orientation = newOrientation;
        }
        else {
            if (newWidth === windowWidth && newHeight === windowHeight) return false;
            windowWidth = newWidth;
            windowHeight = newHeight;
        }

        this._size.emit('resize');
        dirtyQueue.push(function(){
            this._size.emit('resize');
        }.bind(this));
    }

    module.exports = Context;
});