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