diff --git a/packages/ui/attrs2.js b/packages/ui/attrs2.js new file mode 100644 index 0000000000..450292e1fa --- /dev/null +++ b/packages/ui/attrs2.js @@ -0,0 +1,86 @@ + +// An AttributeHandler object is responsible for updating a particular attribute +// of a particular element. AttributeHandler subclasses implement +// browser-specific logic for dealing with particular attributes across +// different browsers. +// +// To define a new type of AttributeHandler, use +// `var FooHandler = AttributeHandler.extend({ update: function ... })` +// where the `update` function takes arguments `(element, oldValue, value)`. +// The `element` argument is always the same between calls to `update` on +// the same instance. `oldValue` and `value` are each either `null` or +// a Unicode string of the type that might be passed to the value argument +// of `setAttribute` (i.e. not an HTML string with character references). +// When an AttributeHandler is installed, an initial call to `update` is +// always made with `oldValue = null`. The `update` method can access +// `this.name` if the AttributeHandler class is a generic one that applies +// to multiple attribute names. +// +// AttributeHandlers can store custom properties on `this`, as long as they +// don't use the names `element`, `name`, `value`, and `oldValue`. +// +// AttributeHandlers can't influence how attributes appear in rendered HTML, +// only how they are updated after materialization as DOM. + +AttributeHandler2 = function (name, value) { + this.name = name; + this.value = value; +}; + +_extend(AttributeHandler2.prototype, { + update: function (element, oldValue, value) { + if (value === null) { + if (oldValue !== null) + element.removeAttribute(this.name); + } else { + element.setAttribute(this.name, this.value); + } + } +}); + +AttributeHandler2.extend = function (options) { + var curType = this; + var subType = function AttributeHandlerSubtype(/*arguments*/) { + AttributeHandler2.apply(this, arguments); + }; + subType.prototype = new curType; + subType.extend = curType.extend; + if (options) + _.extend(subType.prototype, options); + return subType; +}; + +// Value of a ClassHandler is either a string or an array. +var ClassHandler = AttributeHandler2.extend({ + update: function (element, oldValue, value) { + var oldClasses = oldValue ? _.compact(oldValue.split(' ')) : []; + var newClasses = value ? _.compact(value.split(' ')) : []; + + // the current classes on the element, which we will mutate. + var classes = _.compact(element.className.split(' ')); + + // optimize this later (to be asymptotically faster) if necessary + _.each(oldClasses, function (c) { + if (_.indexOf(newClasses, c) < 0) + classes = _.without(classes, c); + }); + _.each(newClasses, function (c) { + if (_.indexOf(oldClasses, c) < 0 && + _.indexOf(classes, c) < 0) + classes.push(c); + }); + + element.className = classes.join(' '); + } +}); + +// XXX make it possible for users to register attribute handlers! +makeAttributeHandler2 = function (name, value) { + // XXX will need one for 'style' on IE, though modern browsers + // seem to handle setAttribute ok. + if (name === 'class') { + return new ClassHandler(name, value); + } else { + return new AttributeHandler2(name, value); + } +}; diff --git a/packages/ui/exceptions.js b/packages/ui/exceptions.js new file mode 100644 index 0000000000..b72fec2160 --- /dev/null +++ b/packages/ui/exceptions.js @@ -0,0 +1,34 @@ + +var debugFunc; + +// Meteor UI calls into user code in many places, and it's nice to catch exceptions +// propagated from user code immediately so that the whole system doesn't just +// break. Catching exceptions is easy; reporting them is hard. This helper +// reports exceptions. +// +// Usage: +// +// ``` +// try { +// // ... someStuff ... +// } catch (e) { +// reportUIException(e); +// } +// ``` +// +// An optional second argument overrides the default message. + +reportUIException = function (e, msg) { + if (! debugFunc) + // adapted from Deps + debugFunc = function () { + return (typeof Meteor !== "undefined" ? Meteor._debug : + ((typeof console !== "undefined") && console.log ? console.log : + function () {})); + }; + + // In Chrome, `e.stack` is a multiline string that starts with the message + // and contains a stack trace. Furthermore, `console.log` makes it clickable. + // `console.log` supplies the space between the two arguments. + debugFunc(msg || 'Exception in Meteor UI:', e.stack || e.message); +}; diff --git a/packages/ui/package.js b/packages/ui/package.js index 4110d1444c..b77940e4b9 100644 --- a/packages/ui/package.js +++ b/packages/ui/package.js @@ -13,13 +13,14 @@ Package.on_use(function (api) { api.use('minimongo'); // for idStringify api.use('observe-sequence'); - api.add_files(['base.js']); + api.add_files(['exceptions.js', 'base.js']); api.add_files(['dombackend.js', 'dombackend2.js', 'domrange.js'], 'client'); api.add_files(['attrs.js', + 'attrs2.js', 'render.js', 'render2.js', 'components.js', diff --git a/packages/ui/render2.js b/packages/ui/render2.js index 8eb68500b1..e652098bbd 100644 --- a/packages/ui/render2.js +++ b/packages/ui/render2.js @@ -106,6 +106,46 @@ var insert = function (nodeOrRange, parent, before) { } }; +// Update attributes on `elem` to the dictionary `attrs`, using the +// dictionary of existing `handlers` if provided. +// +// Values in the `attrs` dictionary are in pseudo-DOM form -- a string, +// CharRef, or array of strings and CharRefs -- but they are passed to +// the AttributeHandler in string form. +var updateAttributes = function(elem, attrs, handlers) { + if (handlers) { + for (var k in handlers) { + if (! attrs.hasOwnProperty(k)) { + // remove old attributes (and handlers) + var handler = handlers[k]; + var oldValue = handler.value; + handler.value = null; + handler.update(elem, oldValue, null); + delete handlers[k]; + } + } + } + + for (var k in attrs) { + var handler; + var oldValue; + var value = attributeValueToString(attrs[k]); + if ((! handlers) || (! handlers.hasOwnProperty(k))) { + // make new handler + checkAttributeName(k); + handler = makeAttributeHandler2(k, value); + if (handlers) + handlers[k] = handler; + oldValue = null; + } else { + handler = handlers[k]; + oldValue = handler.value; + } + handler.value = value; + handler.update(elem, oldValue, value); + } +}; + // Convert the pseudoDOM `node` into reactive DOM nodes and insert them // into the element or DomRange `parent`, before the node or id `before`. var materialize = function (node, parent, before) { @@ -129,13 +169,23 @@ var materialize = function (node, parent, before) { var elem = document.createElement(node.tagName); if (node.attrs) { var attrs = node.attrs; - // XXX make attributes reactive! - if (typeof attrs === 'function') - attrs = attrs(); - _.each(attrs, function (v, k) { - checkAttributeName(k); - elem.setAttribute(k, attributeValueToString(v)); - }); + if (typeof attrs === 'function') { + var attrUpdater = Deps.autorun(function (c) { + if (! c.handlers) + c.handlers = {}; + + try { + updateAttributes(elem, attrs(), c.handlers); + } catch (e) { + reportUIException(e); + } + }); + UI.DomBackend2.onRemoveElement(elem, function () { + attrUpdater.stop(); + }); + } else { + updateAttributes(elem, attrs); + } } _.each(node, function (child) { materialize(child, elem);