/** @module ember @submodule ember-views */ import jQuery from "ember-views/system/jquery"; import Ember from "ember-metal/core"; import create from 'ember-metal/platform/create'; import environment from "ember-metal/environment"; import { normalizeProperty } from "morph/dom-helper/prop"; // The HTML spec allows for "omitted start tags". These tags are optional // when their intended child is the first thing in the parent tag. For // example, this is a tbody start tag: // // <table> // <tbody> // <tr> // // The tbody may be omitted, and the browser will accept and render: // // <table> // <tr> // // However, the omitted start tag will still be added to the DOM. Here // we test the string and context to see if the browser is about to // perform this cleanup, but with a special allowance for disregarding // <script tags. This disregarding of <script being the first child item // may bend the official spec a bit, and is only needed for Handlebars // templates. // // http://www.whatwg.org/specs/web-apps/current-work/multipage/syntax.html#optional-tags // describes which tags are omittable. The spec for tbody and colgroup // explains this behavior: // // http://www.whatwg.org/specs/web-apps/current-work/multipage/tables.html#the-tbody-element // http://www.whatwg.org/specs/web-apps/current-work/multipage/tables.html#the-colgroup-element // var omittedStartTagChildren; var omittedStartTagChildTest = /(?:<script)*.*?<([\w:]+)/i; function detectOmittedStartTag(dom, string, contextualElement) { omittedStartTagChildren = omittedStartTagChildren || { tr: dom.createElement('tbody'), col: dom.createElement('colgroup') }; // Omitted start tags are only inside table tags. if (contextualElement.tagName === 'TABLE') { var omittedStartTagChildMatch = omittedStartTagChildTest.exec(string); if (omittedStartTagChildMatch) { // It is already asserted that the contextual element is a table // and not the proper start tag. Just look up the start tag. return omittedStartTagChildren[omittedStartTagChildMatch[1].toLowerCase()]; } } } function ClassSet() { this.seen = create(null); this.list = []; } ClassSet.prototype = { add: function(string) { if (this.seen[string] === true) { return; } this.seen[string] = true; this.list.push(string); } }; var BAD_TAG_NAME_TEST_REGEXP = /[^a-zA-Z0-9\-]/; var BAD_TAG_NAME_REPLACE_REGEXP = /[^a-zA-Z0-9\-]/g; function stripTagName(tagName) { if (!tagName) { return tagName; } if (!BAD_TAG_NAME_TEST_REGEXP.test(tagName)) { return tagName; } return tagName.replace(BAD_TAG_NAME_REPLACE_REGEXP, ''); } var BAD_CHARS_REGEXP = /&(?!\w+;)|[<>"'`]/g; var POSSIBLE_CHARS_REGEXP = /[&<>"'`]/; function escapeAttribute(value) { // Stolen shamelessly from Handlebars var escape = { "<": "<", ">": ">", '"': """, "'": "'", "`": "`" }; var escapeChar = function(chr) { return escape[chr] || "&"; }; var string = value.toString(); if (!POSSIBLE_CHARS_REGEXP.test(string)) { return string; } return string.replace(BAD_CHARS_REGEXP, escapeChar); } // IE 6/7 have bugs around setting names on inputs during creation. // From http://msdn.microsoft.com/en-us/library/ie/ms536389(v=vs.85).aspx: // "To include the NAME attribute at run time on objects created with the createElement method, use the eTag." var canSetNameOnInputs = (function() { if (!environment.hasDOM) { return false; } var div = document.createElement('div'); var el = document.createElement('input'); el.setAttribute('name', 'foo'); div.appendChild(el); return !!div.innerHTML.match('foo'); })(); /** `Ember.RenderBuffer` gathers information regarding the view and generates the final representation. `Ember.RenderBuffer` will generate HTML which can be pushed to the DOM. ```javascript var buffer = new Ember.RenderBuffer('div', contextualElement); ``` @method renderBuffer @namespace Ember @param {String} tagName tag name (such as 'div' or 'p') used for the buffer */ var RenderBuffer = function(domHelper) { this.buffer = null; this.childViews = []; Ember.assert("RenderBuffer requires a DOM helper to be passed to its constructor.", !!domHelper); this.dom = domHelper; }; RenderBuffer.prototype = { reset: function(tagName, contextualElement) { this.tagName = tagName; this.buffer = null; this._element = null; this._outerContextualElement = contextualElement; this.elementClasses = null; this.elementId = null; this.elementAttributes = null; this.elementProperties = null; this.elementTag = null; this.elementStyle = null; this.childViews.length = 0; }, // The root view's element _element: null, // The root view's contextualElement _outerContextualElement: null, /** An internal set used to de-dupe class names when `addClass()` is used. After each call to `addClass()`, the `classes` property will be updated. @private @property elementClasses @type Array @default null */ elementClasses: null, /** Array of class names which will be applied in the class attribute. You can use `setClasses()` to set this property directly. If you use `addClass()`, it will be maintained for you. @property classes @type Array @default null */ classes: null, /** The id in of the element, to be applied in the id attribute. You should not set this property yourself, rather, you should use the `id()` method of `Ember.RenderBuffer`. @property elementId @type String @default null */ elementId: null, /** A hash keyed on the name of the attribute and whose value will be applied to that attribute. For example, if you wanted to apply a `data-view="Foo.bar"` property to an element, you would set the elementAttributes hash to `{'data-view':'Foo.bar'}`. You should not maintain this hash yourself, rather, you should use the `attr()` method of `Ember.RenderBuffer`. @property elementAttributes @type Hash @default {} */ elementAttributes: null, /** A hash keyed on the name of the properties and whose value will be applied to that property. For example, if you wanted to apply a `checked=true` property to an element, you would set the elementProperties hash to `{'checked':true}`. You should not maintain this hash yourself, rather, you should use the `prop()` method of `Ember.RenderBuffer`. @property elementProperties @type Hash @default {} */ elementProperties: null, /** The tagname of the element an instance of `Ember.RenderBuffer` represents. Usually, this gets set as the first parameter to `Ember.RenderBuffer`. For example, if you wanted to create a `p` tag, then you would call ```javascript Ember.RenderBuffer('p', contextualElement) ``` @property elementTag @type String @default null */ elementTag: null, /** A hash keyed on the name of the style attribute and whose value will be applied to that attribute. For example, if you wanted to apply a `background-color:black;` style to an element, you would set the elementStyle hash to `{'background-color':'black'}`. You should not maintain this hash yourself, rather, you should use the `style()` method of `Ember.RenderBuffer`. @property elementStyle @type Hash @default {} */ elementStyle: null, pushChildView: function (view) { var index = this.childViews.length; this.childViews[index] = view; this.push("<script id='morph-"+index+"' type='text/x-placeholder'>\x3C/script>"); }, hydrateMorphs: function (contextualElement) { var childViews = this.childViews; var el = this._element; for (var i=0,l=childViews.length; i<l; i++) { var childView = childViews[i]; var ref = el.querySelector('#morph-'+i); Ember.assert('An error occurred while setting up template bindings. Please check ' + (childView && childView._parentView && childView._parentView._debugTemplateName ? '"' + childView._parentView._debugTemplateName + '" template ' : '' ) + 'for invalid markup or bindings within HTML comments.', ref); var parent = ref.parentNode; childView._morph = this.dom.insertMorphBefore( parent, ref, parent.nodeType === 1 ? parent : contextualElement ); parent.removeChild(ref); } }, /** Adds a string of HTML to the `RenderBuffer`. @method push @param {String} string HTML to push into the buffer @chainable */ push: function(content) { if (typeof content === 'string') { if (this.buffer === null) { this.buffer = ''; } Ember.assert("A string cannot be pushed into the buffer after a fragment", !this.buffer.nodeType); this.buffer += content; } else { Ember.assert("A fragment cannot be pushed into a buffer that contains content", !this.buffer); this.buffer = content; } return this; }, /** Adds a class to the buffer, which will be rendered to the class attribute. @method addClass @param {String} className Class name to add to the buffer @chainable */ addClass: function(className) { // lazily create elementClasses this.elementClasses = (this.elementClasses || new ClassSet()); this.elementClasses.add(className); this.classes = this.elementClasses.list; return this; }, setClasses: function(classNames) { this.elementClasses = null; var len = classNames.length; var i; for (i = 0; i < len; i++) { this.addClass(classNames[i]); } }, /** Sets the elementID to be used for the element. @method id @param {String} id @chainable */ id: function(id) { this.elementId = id; return this; }, // duck type attribute functionality like jQuery so a render buffer // can be used like a jQuery object in attribute binding scenarios. /** Adds an attribute which will be rendered to the element. @method attr @param {String} name The name of the attribute @param {String} value The value to add to the attribute @chainable @return {Ember.RenderBuffer|String} this or the current attribute value */ attr: function(name, value) { var attributes = this.elementAttributes = (this.elementAttributes || {}); if (arguments.length === 1) { return attributes[name]; } else { attributes[name] = value; } return this; }, /** Remove an attribute from the list of attributes to render. @method removeAttr @param {String} name The name of the attribute @chainable */ removeAttr: function(name) { var attributes = this.elementAttributes; if (attributes) { delete attributes[name]; } return this; }, /** Adds a property which will be rendered to the element. @method prop @param {String} name The name of the property @param {String} value The value to add to the property @chainable @return {Ember.RenderBuffer|String} this or the current property value */ prop: function(name, value) { var properties = this.elementProperties = (this.elementProperties || {}); if (arguments.length === 1) { return properties[name]; } else { properties[name] = value; } return this; }, /** Remove an property from the list of properties to render. @method removeProp @param {String} name The name of the property @chainable */ removeProp: function(name) { var properties = this.elementProperties; if (properties) { delete properties[name]; } return this; }, /** Adds a style to the style attribute which will be rendered to the element. @method style @param {String} name Name of the style @param {String} value @chainable */ style: function(name, value) { this.elementStyle = (this.elementStyle || {}); this.elementStyle[name] = value; return this; }, generateElement: function() { var tagName = this.tagName; var id = this.elementId; var classes = this.classes; var attrs = this.elementAttributes; var props = this.elementProperties; var style = this.elementStyle; var styleBuffer = ''; var attr, prop, tagString; if (attrs && attrs.name && !canSetNameOnInputs) { // IE allows passing a tag to createElement. See note on `canSetNameOnInputs` above as well. tagString = '<'+stripTagName(tagName)+' name="'+escapeAttribute(attrs.name)+'">'; } else { tagString = tagName; } var element = this.dom.createElement(tagString, this.outerContextualElement()); if (id) { this.dom.setAttribute(element, 'id', id); this.elementId = null; } if (classes) { this.dom.setAttribute(element, 'class', classes.join(' ')); this.classes = null; this.elementClasses = null; } if (style) { for (prop in style) { styleBuffer += (prop + ':' + style[prop] + ';'); } this.dom.setAttribute(element, 'style', styleBuffer); this.elementStyle = null; } if (attrs) { for (attr in attrs) { this.dom.setAttribute(element, attr, attrs[attr]); } this.elementAttributes = null; } if (props) { for (prop in props) { var normalizedCase = normalizeProperty(element, prop.toLowerCase()) || prop; this.dom.setPropertyStrict(element, normalizedCase, props[prop]); } this.elementProperties = null; } this._element = element; }, /** @method element @return {DOMElement} The element corresponding to the generated HTML of this buffer */ element: function() { var content = this.innerContent(); // No content means a text node buffer, with the content // in _element. Ember._BoundView is an example. if (content === null) { return this._element; } var contextualElement = this.innerContextualElement(content); this.dom.detectNamespace(contextualElement); if (!this._element) { this._element = this.dom.createDocumentFragment(); } if (content.nodeType) { this._element.appendChild(content); } else { var nodes; nodes = this.dom.parseHTML(content, contextualElement); while (nodes[0]) { this._element.appendChild(nodes[0]); } } // This should only happen with legacy string buffers if (this.childViews.length > 0) { this.hydrateMorphs(contextualElement); } return this._element; }, /** Generates the HTML content for this buffer. @method string @return {String} The generated HTML */ string: function() { if (this._element) { // Firefox versions < 11 do not have support for element.outerHTML. var thisElement = this.element(); var outerHTML = thisElement.outerHTML; if (typeof outerHTML === 'undefined') { return jQuery('<div/>').append(thisElement).html(); } return outerHTML; } else { return this.innerString(); } }, outerContextualElement: function() { if (this._outerContextualElement === undefined) { Ember.deprecate("The render buffer expects an outer contextualElement to exist." + " This ensures DOM that requires context is correctly generated (tr, SVG tags)." + " Defaulting to document.body, but this will be removed in the future"); this.outerContextualElement = document.body; } return this._outerContextualElement; }, innerContextualElement: function(html) { var innerContextualElement; if (this._element && this._element.nodeType === 1) { innerContextualElement = this._element; } else { innerContextualElement = this.outerContextualElement(); } var omittedStartTag; if (html) { omittedStartTag = detectOmittedStartTag(this.dom, html, innerContextualElement); } return omittedStartTag || innerContextualElement; }, innerString: function() { var content = this.innerContent(); if (content && !content.nodeType) { return content; } }, innerContent: function() { return this.buffer; } }; export default RenderBuffer;