/* Copyright © 2015-2016 David Valdman */
define(function(require, exports, module) {
var DOMOutput = require('./_DOMOutput');
var EventHandler = require('../events/EventHandler');
var Stream = require('../streams/Stream');
var SizeNode = require('../core/nodes/SizeNode');
var LayoutNode = require('../core/nodes/LayoutNode');
var sizeAlgebra = require('../core/algebras/size');
var layoutAlgebra = require('../core/algebras/layout');
var dirtyQueue = require('../core/queues/dirtyQueue');
var isTouchEnabled = 'ontouchstart' in window;
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
* @uses DOM._DOMOutput
* @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.classList = [];
this.content = '';
this._cachedSize = null;
this._allocator = null;
this._currentTarget = null;
this._elementOutput = new DOMOutput();
this._eventOutput = new EventHandler();
EventHandler.setOutputHandler(this, this._eventOutput);
this._eventForwarder = function _eventForwarder(event) {
this._eventOutput.emit(event.type, event);
}.bind(this);
this._sizeNode = new SizeNode();
this._layoutNode = new LayoutNode();
this._size = new EventHandler();
this._layout = new EventHandler();
this.size = Stream.lift(function elementSizeLift(sizeSpec, parentSize) {
if (!parentSize) return false; // occurs when surface is never added
return sizeAlgebra(sizeSpec, parentSize);
}, [this._sizeNode, this._size]);
this.layout = Stream.lift(function(parentSpec, objectSpec, size) {
if (!parentSpec || !size) return false;
return (objectSpec)
? layoutAlgebra(objectSpec, parentSpec, size)
: parentSpec;
}, [this._layout, this._layoutNode, this.size]);
this.layout.on('start', function(){
if (!this._currentTarget) return;
this._elementOutput.promoteLayer(this._currentTarget);
}.bind(this));
this.layout.on('update', function(layout){
if (!this._currentTarget) return;
this._elementOutput.commitLayout(this._currentTarget, layout);
}.bind(this));
this.layout.on('end', function(layout){
if (!this._currentTarget) return;
this._elementOutput.commitLayout(this._currentTarget, layout);
this._elementOutput.demoteLayer(this._currentTarget);
}.bind(this));
this.size.on('start', commitSize.bind(this));
this.size.on('update', commitSize.bind(this));
this.size.on('end', commitSize.bind(this));
if (options) this.setOptions(options);
}
Surface.prototype = Object.create(DOMOutput.prototype);
Surface.prototype.constructor = Surface;
Surface.prototype.elementType = 'div'; // Default tagName. Can be overridden in options.
Surface.prototype.elementClass = 'samsara-surface';
function commitSize(size){
if (!this._currentTarget) return;
var shouldResize = this._elementOutput.commitSize(this._currentTarget, size);
this._cachedSize = size;
if (shouldResize) this.emit('resize', size);
}
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);
});
}
/**
* Set or overwrite innerHTML content of this Surface.
*
* @method setContent
* @param content {String|DocumentFragment} HTML content
*/
Surface.prototype.setContent = function setContent(content){
if (this.content !== content){
this.content = content;
if (this._currentTarget){
dirtyQueue.push(function(){
this._elementOutput.applyContent(this._currentTarget, content);
}.bind(this));
}
}
};
/**
* Return innerHTML content of this Surface.
*
* @method getContent
* @return {String}
*/
Surface.prototype.getContent = function getContent(){
return this.content;
};
/**
* Setter for HTML attributes.
*
* @method setAttributes
* @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];
}
if (this._currentTarget){
dirtyQueue.push(function(){
this._elementOutput.applyAttributes(this._currentTarget, attributes);
}.bind(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
* @param properties {Object} CSS properties
*/
Surface.prototype.setProperties = function setProperties(properties) {
for (var key in properties)
this.properties[key] = properties[key];
if (this._currentTarget){
dirtyQueue.push(function(){
this._elementOutput.applyProperties(this._currentTarget, properties);
}.bind(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
* @param className {String} Class name
*/
Surface.prototype.addClass = function addClass(className) {
if (this.classList.indexOf(className) < 0) {
this.classList.push(className);
if (this._currentTarget){
dirtyQueue.push(function(){
this._elementOutput.applyClasses(this._currentTarget, this.classList);
}.bind(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.classList.splice(i, 1);
if (this._currentTarget){
dirtyQueue.push(function(){
this._elementOutput.removeClasses(this._currentTarget, this.classList);
}.bind(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
* @param classlist {String[]} ClassList
*/
Surface.prototype.setClasses = function setClasses(classList) {
for (var i = 0; i < classList.length; i++) {
this.addClass(classList[i]);
}
};
/**
* Get array of CSS classes attached to this Surface.
*
* @method getClasslist
* @return {String[]}
*/
Surface.prototype.getClassList = function getClassList() {
return this.classList;
};
/**
* Apply the DOM's Element.querySelector to the Surface's current DOM target.
* Returns the first node matching the selector within the Surface's content.
*
* @method querySelector
* @return {Element}
*/
Surface.prototype.querySelector = function querySelector(selector){
if (this._currentTarget)
return this._elementOutput.querySelector(this._currentTarget, selector);
};
/**
* Apply the DOM's Element.querySelectorAll to the Surface's current DOM target.
* Returns a list of nodes matching the selector within the Surface's content.
*
* @method querySelector
* @return {NodeList}
*/
Surface.prototype.querySelectorAll = function querySelectorAll(selector){
if (this._currentTarget)
return this._elementOutput.querySelectorAll(this._currentTarget, selector);
};
/**
* 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;
};
/**
* Adds a handler to the `type` channel which will be executed on `emit`.
*
* @method on
*
* @param type {String} DOM event channel name, e.g., "click", "touchmove"
* @param handler {Function} Handler. It's only argument will be an emitted data payload.
*/
Surface.prototype.on = function on(type, handler) {
if (this._currentTarget)
this._elementOutput.on(this._currentTarget, type, this._eventForwarder);
EventHandler.prototype.on.apply(this._eventOutput, arguments);
};
/**
* Adds a handler to the `type` channel which will be executed on `emit` once and then removed.
*
* @method once
*
* @param type {String} DOM event channel name, e.g., "click", "touchmove"
* @param handler {Function} Handler. It's only argument will be an emitted data payload.
*/
Surface.prototype.once = function on(type, handler){
if (this._currentTarget)
this._elementOutput.once(this._currentTarget, type, this._eventForwarder);
EventHandler.prototype.once.apply(this._eventOutput, arguments);
};
/**
* Removes a previously added handler to the `type` channel.
* Undoes the work of `on`.
*
* @method off
* @param type {String} DOM event channel name e.g., "click", "touchmove"
* @param handler {Function} Handler
*/
Surface.prototype.off = function off(type, handler) {
if (this._currentTarget)
this._elementOutput.off(this._currentTarget, type, this._eventForwarder);
EventHandler.prototype.off.apply(this._eventOutput, arguments);
};
/**
* 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 {DOMAllocator} Allocator
*/
Surface.prototype.setup = function setup(allocator) {
if (this._currentTarget) return;
this._allocator = allocator;
// create element of specific type
var target = allocator.allocate(this.elementType);
this._currentTarget = target;
// 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);
}
for (var type in this._eventOutput.listeners)
this._elementOutput.on(target, type, this._eventForwarder);
this.deploy(this._currentTarget);
};
/**
* Clear the HTML contents of the Surface and remove it from the Render Tree.
* The DOM node the Surface occupied will be freed to a pool and can be used by another Surface.
* The Surface can be added to the render tree again and all its data (properties, event listeners, etc)
* will be restored.
*
* @method remove
*/
Surface.prototype.remove = function remove() {
var target = this._currentTarget;
if (!target) return;
for (var type in this._eventOutput.listeners)
this._elementOutput.off(target, type, this._eventForwarder);
// cache the target's contents for later deployment
this.recall(target);
this._allocator.deallocate(target);
this._allocator = null;
this._currentTarget = null;
};
/**
* 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) {
this._elementOutput.makeVisible(target);
this._elementOutput.applyClasses(target, this.classList);
this._elementOutput.applyProperties(target, this.properties);
this._elementOutput.applyAttributes(target, this.attributes);
this._elementOutput.applyContent(target, this.content);
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');
this._elementOutput.removeClasses(target, this.classList);
this._elementOutput.removeProperties(target, this.properties);
this._elementOutput.removeAttributes(target, this.attributes);
this.content = this._elementOutput.recallContent(target);
this._elementOutput.makeInvisible(target);
};
/**
* Getter for size.
*
* @method getSize
* @return {Number[]}
*/
Surface.prototype.getSize = function getSize() {
// TODO: remove cachedSize
return 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});
};
/**
* 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});
};
/**
* 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});
};
/**
* 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});
};
/**
* 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._elementOutput._originDirty = true;
};
/**
* Setter for opacity.
*
* @method setOpacity
* @param opacity {Number} Opacity
*/
Surface.prototype.setOpacity = function setOpacity(opacity){
this._layoutNode.set({opacity : opacity});
this._elementOutput._opacityDirty = true;
};
module.exports = Surface;
});