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;
};