1 sc_require('ext/handlebars'); 2 3 /** 4 Adds the `bind`, `bindAttr`, and `boundIf` helpers to Handlebars. 5 6 # bind 7 8 `bind` can be used to display a value, then update that value if it changes. 9 For example, if you wanted to print the `title` property of `content`: 10 11 {{bind "content.title"}} 12 13 This will return the `title` property as a string, then create a new observer 14 at the specified path. If it changes, it will update the value in DOM. Note 15 that this will only work with SC.Object and subclasses, since it relies on 16 SproutCore's KVO system. 17 18 # bindAttr 19 20 `bindAttr` allows you to create a binding between DOM element attributes and 21 SproutCore objects. For example: 22 23 <img {{bindAttr src="imageUrl" alt="imageTitle"}}> 24 25 # boundIf 26 27 Use the `boundIf` helper to create a conditional that re-evaluates whenever 28 the bound value changes. 29 30 {{#boundIf "content.shouldDisplayTitle"}} 31 {{content.title}} 32 {{/boundIf}} 33 */ 34 (function() { 35 // Binds a property into the DOM. This will create a hook in DOM that the 36 // KVO system will look for and upate if the property changes. 37 var bind = function(property, options, preserveContext, shouldDisplay) { 38 var data = options.data, 39 fn = options.fn, 40 inverse = options.inverse, 41 view = data.view; 42 43 // Set up observers for observable objects 44 if (this.isObservable) { 45 // Create the view that will wrap the output of this template/property and 46 // add it to the nearest view's childViews array. 47 // See the documentation of SC._BindableSpan for more. 48 var bindView = view.createChildView(SC._BindableSpan, { 49 preserveContext: preserveContext, 50 shouldDisplayFunc: shouldDisplay, 51 displayTemplate: fn, 52 inverseTemplate: inverse, 53 property: property, 54 previousContext: this, 55 tagName: (options.hash.tagName || "span"), 56 isEscaped: options.hash.escaped 57 }); 58 59 var observer, invoker; 60 61 view.get('childViews').push(bindView); 62 63 observer = function() { 64 if (bindView.get('layer')) { 65 bindView.rerender(); 66 } else { 67 // If no layer can be found, we can assume somewhere 68 // above it has been re-rendered, so remove the 69 // observer. 70 this.removeObserver(property, invoker); 71 } 72 }; 73 74 invoker = function() { 75 this.invokeOnce(observer); 76 }; 77 78 // Observe the given property on the context and 79 // tells the SC._BindableSpan to re-render. 80 this.addObserver(property, invoker); 81 82 var context = bindView.renderContext(bindView.get('tagName')); 83 bindView.renderToContext(context); 84 return new Handlebars.SafeString(context.join()); 85 } else { 86 // The object is not observable, so just render it out and 87 // be done with it. 88 return SC.getPath(this, property); 89 } 90 }; 91 92 Handlebars.registerHelper('bind', function(property, fn) { 93 return bind.call(this, property, fn, false, function(result) { return !SC.none(result); } ); 94 }); 95 96 Handlebars.registerHelper('boundIf', function(property, fn) { 97 if(fn) { 98 return bind.call(this, property, fn, true, function(result) { 99 if (SC.typeOf(result) === SC.T_ARRAY) { 100 if (result.length !== 0) { return true; } 101 return false; 102 } else { 103 return !!result; 104 } 105 } ); 106 } else { 107 throw new Error("Cannot use boundIf helper without a block."); 108 } 109 }); 110 })(); 111 112 Handlebars.registerHelper('with', function(context, options) { 113 return Handlebars.helpers.bind.call(options.contexts[0], context, options); 114 }); 115 116 Handlebars.registerHelper('if', function(context, options) { 117 return Handlebars.helpers.boundIf.call(options.contexts[0], context, options); 118 }); 119 120 Handlebars.registerHelper('unless', function(context, options) { 121 var fn = options.fn, inverse = options.inverse; 122 123 options.fn = inverse; 124 options.inverse = fn; 125 126 return Handlebars.helpers.boundIf.call(options.contexts[0], context, options); 127 }); 128 129 Handlebars.registerHelper('bindAttr', function(options) { 130 var attrs = options.hash; 131 var view = options.data.view; 132 var ret = []; 133 134 // Generate a unique id for this element. This will be added as a 135 // data attribute to the element so it can be looked up when 136 // the bound property changes. 137 var dataId = SC._uuid++; 138 139 // Handle classes differently, as we can bind multiple classes 140 var classBindings = attrs['class']; 141 if (classBindings != null) { 142 var classResults = SC.Handlebars.bindClasses(this, classBindings, view, dataId); 143 ret.push('class="'+classResults.join(' ')+'"'); 144 delete attrs['class']; 145 } 146 147 var attrKeys = SC.keys(attrs); 148 149 // For each attribute passed, create an observer and emit the 150 // current value of the property as an attribute. 151 attrKeys.forEach(function(attr) { 152 var property = attrs[attr]; 153 var value = this.getPath(property); 154 155 var observer, invoker; 156 157 observer = function observer() { 158 var result = this.getPath(property); 159 var elem = view.$("[data-handlebars-id='" + dataId + "']"); 160 161 // If we aren't able to find the element, it means the element 162 // to which we were bound has been removed from the view. 163 // In that case, we can assume the template has been re-rendered 164 // and we need to clean up the observer. 165 if (elem.length === 0) { 166 this.removeObserver(property, invoker); 167 return; 168 } 169 170 var currentValue = elem.attr(attr); 171 172 // A false result will remove the attribute from the element. This is 173 // to support attributes such as disabled, whose presence is meaningful. 174 if (result === NO && currentValue) { 175 elem.removeAttr(attr); 176 177 // Likewise, a true result will set the attribute's name as the value. 178 } else if (result === YES && currentValue !== attr) { 179 elem.attr(attr, attr); 180 181 } else if (currentValue !== result) { 182 elem.attr(attr, result); 183 } 184 }; 185 186 invoker = function() { 187 this.invokeOnce(observer); 188 }; 189 190 // Add an observer to the view for when the property changes. 191 // When the observer fires, find the element using the 192 // unique data id and update the attribute to the new value. 193 this.addObserver(property, invoker); 194 195 // Use the attribute's name as the value when it is YES 196 if (value === YES) { 197 value = attr; 198 } 199 200 // Do not add the attribute when the value is false 201 if (value !== NO) { 202 if (SC.typeOf(value) === SC.T_STRING) { 203 value = value.replace(/"/g, '"'); 204 } 205 // Return the current value, in the form src="foo.jpg" 206 ret.push(attr + '="' + value + '"'); 207 } 208 }, this); 209 210 // Add the unique identifier 211 ret.push('data-handlebars-id="'+dataId+'"'); 212 return new Handlebars.SafeString(ret.join(' ')); 213 }); 214 215 /** 216 Helper that, given a space-separated string of property paths and a context, 217 returns an array of class names. Calling this method also has the side effect 218 of setting up observers at those property paths, such that if they change, 219 the correct class name will be reapplied to the DOM element. 220 221 For example, if you pass the string "fooBar", it will first look up the "fooBar" 222 value of the context. If that value is YES, it will add the "foo-bar" class 223 to the current element (i.e., the dasherized form of "fooBar"). If the value 224 is a string, it will add that string as the class. Otherwise, it will not add 225 any new class name. 226 227 @param {SC.Object} context The context from which to lookup properties 228 @param {String} classBindings A string, space-separated, of class bindings to use 229 @param {SC.View} view The view in which observers should look for the element to update 230 @param {String} id Optional id use to lookup elements 231 232 @returns {Array} An array of class names to add 233 */ 234 SC.Handlebars.bindClasses = function(context, classBindings, view, id) { 235 var ret = [], newClass, value, elem; 236 237 // Helper method to retrieve the property from the context and 238 // determine which class string to return, based on whether it is 239 // a Boolean or not. 240 var classStringForProperty = function(property) { 241 var val = context.getPath(property); 242 243 // If value is a Boolean and true, return the dasherized property 244 // name. 245 if (val === YES) { 246 // Normalize property path to be suitable for use 247 // as a class name. For exaple, content.foo.barBaz 248 // becomes bar-baz. 249 return SC.String.dasherize(property.split('.').get('lastObject')); 250 251 // If the value is not NO, undefined, or null, return the current 252 // value of the property. 253 } else if (val !== NO && val !== undefined && val !== null) { 254 return val; 255 256 // Nothing to display. Return null so that the old class is removed 257 // but no new class is added. 258 } else { 259 return null; 260 } 261 }; 262 263 // For each property passed, loop through and setup 264 // an observer. 265 classBindings.split(' ').forEach(function(property) { 266 267 // Variable in which the old class value is saved. The observer function 268 // closes over this variable, so it knows which string to remove when 269 // the property changes. 270 var oldClass; 271 272 var observer, invoker; 273 274 // Set up an observer on the context. If the property changes, toggle the 275 // class name. 276 observer = function() { 277 // Get the current value of the property 278 newClass = classStringForProperty(property); 279 elem = id ? view.$("[data-handlebars-id='" + id + "']") : view.$(); 280 281 // If we can't find the element anymore, a parent template has been 282 // re-rendered and we've been nuked. Remove the observer. 283 if (elem.length === 0) { 284 context.removeObserver(property, invoker); 285 } else { 286 // If we had previously added a class to the element, remove it. 287 if (oldClass) { 288 elem.removeClass(oldClass); 289 } 290 291 // If necessary, add a new class. Make sure we keep track of it so 292 // it can be removed in the future. 293 if (newClass) { 294 elem.addClass(newClass); 295 oldClass = newClass; 296 } else { 297 oldClass = null; 298 } 299 } 300 }; 301 302 invoker = function() { 303 this.invokeOnce(observer); 304 }; 305 306 context.addObserver(property, invoker); 307 308 // We've already setup the observer; now we just need to figure out the correct 309 // behavior right now on the first pass through. 310 value = classStringForProperty(property); 311 312 if (value) { 313 ret.push(value); 314 315 // Make sure we save the current value so that it can be removed if the observer 316 // fires. 317 oldClass = value; 318 } 319 }); 320 321 return ret; 322 }; 323