diff --git a/examples/unfinished/shark/client/shark.js b/examples/unfinished/shark/client/shark.js index 3de8da2051..36e038cf87 100644 --- a/examples/unfinished/shark/client/shark.js +++ b/examples/unfinished/shark/client/shark.js @@ -50,13 +50,25 @@ Template.item({ Span = UIComponent.extend({ render: function (buf) { - buf("Hello"); + buf("
Hello
"); } }); Div = UIComponent.extend({ render: function (buf) { - buf("
World
"); + buf("
World", + "", + "
"); } }); diff --git a/packages/ui/base.js b/packages/ui/base.js index a2828be9b0..1ae004c994 100644 --- a/packages/ui/base.js +++ b/packages/ui/base.js @@ -67,10 +67,11 @@ var Component = function Component() { return constrImpl(this, arguments, Component); }; -var _extend = function (tgt, src) { +_extend = function (tgt, src) { for (var k in src) if (src.hasOwnProperty(k)) tgt[k] = src[k]; + return tgt; }; var setSuperType = function (subType, superType) { @@ -136,7 +137,8 @@ _extend(Component, { } }, extendHooks: function (hooks) { - _extend(this._extendHooks, hooks); + this._extendHooks = + _extend(_extend({}, this._extendHooks), hooks); }, // make typeName count as a special option for when `create` // checks for special options, even though it's not diff --git a/packages/ui/dom.js b/packages/ui/dom.js index cf304c5d70..6a9ca4c8ba 100644 --- a/packages/ui/dom.js +++ b/packages/ui/dom.js @@ -46,6 +46,30 @@ var compareElementIndex = function (a, b) { } }; +// Returns true if element a contains node b and is not node b. +var elementContains = function (a, b) { + if (a.nodeType !== 1) /* ELEMENT */ + return false; + if (a === b) + return false; + + if (a.compareDocumentPosition) { + return a.compareDocumentPosition(b) & 0x10; + } else { + // Should be only old IE and maybe other old browsers here. + // Modern Safari has both functions but seems to get contains() wrong. + // IE can't handle b being a text node. We work around this + // by doing a direct parent test now. + b = b.parentNode; + if (! (b && b.nodeType === 1)) /* ELEMENT */ + return false; + if (a === b) + return true; + + return a.contains(b); + } +}; + var insertNodesBefore = function (nodes, parentNode, beforeNode) { if (beforeNode) { $(nodes).insertBefore(beforeNode); @@ -100,51 +124,9 @@ Component({ var html = buf.getHtml(); $(div).append(html); - var start = div.firstChild; - var end = div.lastChild; - var componentsToAttach = buf.componentsToAttach; - // walk div and replace comments with Components - - var wireUpDOM = function (parent) { - var n = parent.firstChild; - while (n) { - var next = n.nextSibling; - if (n.nodeType === 8) { // COMMENT - var comp = componentsToAttach[n.nodeValue]; - if (comp) { - if (parent === div) { - if (n === div.firstChild) - start = comp; - if (n === div.lastChild) - end = comp; - } - comp.attach(parent, n); - parent.removeChild(n); - delete componentsToAttach[n.nodeValue]; - } - } else if (n.nodeType === 1) { // ELEMENT - // recurse through DOM - wireUpDOM(n); - } - n = next; - } - }; - - wireUpDOM(div); - - // We should have attached all specified components, but - // if the comments we generated somehow didn't turn into - // comments (due to bad HTML) we won't have found them, - // in which case we clean them up here just to be safe. - for (var k in componentsToAttach) - componentsToAttach[k].destroy(); - - return { - // start and end will both be null if div is empty - start: start, - end: end - }; + // returns info object with {start, end} + return buf.wireUpDOM(div); }, build: function () { @@ -186,8 +168,15 @@ Component({ c.builtChildren = newChildren; - Deps.nonreactive(function () { - self._built(); + // don't capture dependencies, but provide a + // parent autorun (so that any autoruns created + // from a built callback are stopped on rebuild) + var x = Deps.autorun(function (c) { + if (c.firstRun) + self._built(); + }); + Deps.onInvalidate(function () { + x.stop(); }); }); }, @@ -516,6 +505,26 @@ Component({ this.insertBefore(childOrDom, after.nextSibling, parentNode); }, + containsElement: function (elem) { + var self = this; + self._requireBuilt(); + + var firstNode = self.firstNode(); + var prevNode = firstNode.previousSibling; + var nextNode = self.lastNode().nextSibling; + + // element must not be "above" this component + if (elementContains(elem, firstNode)) + return false; + // element must not be "at or before" prevNode + if (prevNode && compareElementIndex(prevNode, elem) >= 0) + return false; + // element must not be "at or after" nextNode + if (nextNode && compareElementIndex(elem, nextNode) >= 0) + return false; + return true; + }, + $: function (selector) { var self = this; @@ -610,6 +619,24 @@ Component({ oldChild.detach(); self.insertBefore(newChild, nextNode, parentNode); + }, + + built: function () { + var self = this; + var cbs = self._builtCallbacks; + if (cbs) { + for (var i = 0, N = cbs.length; i < N; i++) + cbs[i](self); + self._builtCallbacks.length = 0; + } + }, + + _onNextBuilt: function (cb) { + var self = this; + var cbs = self._builtCallbacks; + if (! cbs) + cbs = self._builtCallbacks = []; + cbs.push(cb); } // If Component is ever emptied, it gets an empty comment node. @@ -633,7 +660,7 @@ Component({ // Next up: // -// - reactive attributes +// - reactive *dynamic* attributes // - content() // - Spacebars compiler // - event maps diff --git a/packages/ui/render.js b/packages/ui/render.js index 89e8a46a0b..35bef5703a 100644 --- a/packages/ui/render.js +++ b/packages/ui/render.js @@ -26,6 +26,19 @@ _UI.encodeSpecialEntities = function (text, isQuoted) { var ATTRIBUTE_NAME_REGEX = /^[^\s"'>/=/]+$/; +// takes a known-to-be non-function, asserts it is +// a string or an array, and produces a string +var stringifyAttrValue = function (v) { + if (typeof v === 'string') + return v; + else if (typeof v.length === 'number') + return Array.prototype.join.call(v, ' '); + else + throw new Error("Expected string or array for attr value"); +}; + +var GT_OR_QUOTE = /[>'"]/; + makeRenderBuffer = function (component, options) { var isPreview = !! options && options.preview; @@ -33,15 +46,60 @@ makeRenderBuffer = function (component, options) { var componentsToAttach = {}; var randomString = Random.id(); var commentUid = 1; + var elementUid = 1; + // Problem: In the template ``, how do + // we make foo and bar insert some HTML in the stream that + // will allow us to find the element later? Since we don't + // tokenize the HTML here, we can't even be sure whether + // they are in the same tag. We can't emit a duplicate + // extra attribute. We can emit different attributes, + // but if every attr tag emits a different attribute, it + // won't be efficient to find them. + // + // Solution: Emit different attributes, data-meteorui-id1 + // and data-meteorui-id2, not knowing if they are on the + // same element or not. Reset the number, which is + // `curDataAttrNumber`, if we can be absolutely sure a tag + // has ended. To detect if a tag has definitely ended, + // we set `greaterThanEndsTag` to true after an attr tag, + // and set it to false if we see a quote character. If we + // a greater-than (`>`) between the attrs and the next quote + // character, we know the tag has ended and we can reset + // `curDataAttrNumber` to 1. When we look for these + // attributes, we look for attribute names with numbers + // between 1 and `maxDataAttrNumber` inclusive. + var curDataAttrNumber = 1; + var maxDataAttrNumber = 0; + var dataAttrs = []; + var greaterThanEndsTag = false; + + var elementsToWire = {}; + + var push = function (/*stringsToPush*/) { + for (var i = 0, N = arguments.length; + greaterThanEndsTag && i < N; + i++) { + // find first greater-than or quote + var match = arguments[i].match(GT_OR_QUOTE); + if (match) { + if (match[0] == '>') + curDataAttrNumber = 1; + // if it's a quote, missed our chance to + // reset the count. either way, stop looking. + greaterThanEndsTag = false; + } + } + strs.push.apply(strs, arguments); + }; var handle = function (arg) { if (typeof arg === 'string') { // "HTML" - strs.push(arg); + push(arg); } else if (arg instanceof Component) { // Component var commentString = randomString + '_' + (commentUid++); - strs.push(''); + push(''); component.add(arg); componentsToAttach[commentString] = arg; } else if (arg.type) { @@ -51,7 +109,7 @@ makeRenderBuffer = function (component, options) { } else if (typeof arg.type === 'function') { var curType; component.autorun(function (c) { - // capture dependencies on this line: + // capture dependencies of this line: var type = arg.type(); if (c.firstRun) { curType = type; @@ -82,8 +140,50 @@ makeRenderBuffer = function (component, options) { for (var attrName in arg.attrs) { if (! ATTRIBUTE_NAME_REGEX.test(attrName)) throw new Error("Illegal HTML attribute name: " + attrName); - // XXX push initial HTML into strs - // XXX set up an autorun + // the declared property of `attrs`, which may + // be a string or array, or a function that returns + // one. + var attrValue = arg.attrs[attrName]; + // the current value, which may be an array or a string. + var initialValue; + + if (typeof attrValue === 'function') { + // calculate the initial value without reactivity. + // once the element exists, recalculate it with + // an autorun. + Deps.nonreactive(function () { + initialValue = attrValue(); + }); + + var elemId = elementUid++; + strs.push('data-meteorui-id', curDataAttrNumber, + '="', elemId, '" '); + if (curDataAttrNumber > maxDataAttrNumber) { + dataAttrs[curDataAttrNumber-1] = + 'data-meteorui-id' + curDataAttrNumber; + maxDataAttrNumber = curDataAttrNumber; + } + curDataAttrNumber++; + greaterThanEndsTag = true; + + elementsToWire[elemId] = { + attrName: attrName, + attrValueFunc: attrValue, + initialValue: initialValue + }; + } else { + initialValue = attrValue; + } + + if (initialValue != null) { + var stringValue = stringifyAttrValue(initialValue); + + // don't call the `push` helper, go around it + strs.push(' ', attrName, '="', + _UI.encodeSpecialEntities(stringValue, true), + '" '); + } + // XXX make attr update hookable } } else { @@ -100,7 +200,120 @@ makeRenderBuffer = function (component, options) { return strs.join(''); }; - buf.componentsToAttach = componentsToAttach; + buf.wireUpDOM = function (root) { + var start = root.firstChild; + var end = root.lastChild; + + // walk div and replace comments with Components + + var recurse = function (parent) { + var n = parent.firstChild; + while (n) { + var next = n.nextSibling; + if (n.nodeType === 8) { // COMMENT + var comp = componentsToAttach[n.nodeValue]; + if (comp) { + if (parent === root) { + if (n === root.firstChild) + start = comp; + if (n === root.lastChild) + end = comp; + } + comp.attach(parent, n); + parent.removeChild(n); + delete componentsToAttach[n.nodeValue]; + } + } else if (n.nodeType === 1) { // ELEMENT + var elemId, callback; + // detect elements with reactive attributes + for (var i = 0; i < maxDataAttrNumber; i++) { + var attrName = dataAttrs[i]; + var elemId = n.getAttribute(attrName); + if (elemId) { + var info = elementsToWire[elemId]; + if (info) + info.element = n; + n.removeAttribute(attrName); + } + } + + // recurse through DOM + recurse(n); + } + n = next; + } + }; + + recurse(root); + + // We should have attached all specified components, but + // if the comments we generated somehow didn't turn into + // comments (due to bad HTML) we won't have found them, + // in which case we clean them up here just to be safe. + for (var k in componentsToAttach) + componentsToAttach[k].destroy(); + + // aid GC + componentsToAttach = null; + + // onNextBuilt callbacks run within the build + // computation and are stopped on rebuild. + component._onNextBuilt(function () { + for (var k in elementsToWire) { + var infoObj = elementsToWire[k]; + if (infoObj.element) { + // element found during DOM traversal + component.autorun(function (c) { + // bring infoObj into our closure as `info`. + // `infoObj` is not safe to close over because + // it's in a for loop, but it is safe during + // the first autorun which is inline. + if (c.firstRun) { + c.info = infoObj; + c.curValue = infoObj.initialValue; + } + var info = c.info; + if (component.stage !== Component.BUILT || + ! component.containsElement(info.element)) { + c.stop(); + return; + } + // capture dependencies of this line: + var newValue = info.attrValueFunc(); + + var oldValue = c.curValue; + if (newValue == null) { + if (oldValue != null) + info.element.removeAttribute(info.attrName); + } else { + var newStringValue = stringifyAttrValue(newValue); + if (oldValue == null) { + info.element.setAttribute( + info.attrName, newStringValue); + } else { + var oldStringValue = + stringifyAttrValue(oldValue); + if (newStringValue !== oldStringValue) { + info.element.setAttribute( + info.attrName, newStringValue); + } + } + } + + c.curValue = newValue; + }); + } + } + elementsToWire = null; + }); + + return { + // start and end will both be null if div is empty + start: start, + end: end + }; + + }; return buf; };