|
24 | 24 | * <code>$firebase( firebaseRef, {objectFactory: NewFactory}).$asObject();</code> |
25 | 25 | */ |
26 | 26 | angular.module('firebase').factory('$FirebaseObject', [ |
27 | | - '$parse', '$firebaseUtils', '$log', |
28 | | - function($parse, $firebaseUtils, $log) { |
| 27 | + '$parse', '$firebaseUtils', '$log', '$interval', |
| 28 | + function($parse, $firebaseUtils, $log, $interval) { |
29 | 29 | /** |
30 | 30 | * This constructor should probably never be called manually. It is used internally by |
31 | 31 | * <code>$firebase.$asObject()</code>. |
|
38 | 38 | * @constructor |
39 | 39 | */ |
40 | 40 | function FirebaseObject($firebase, destroyFn, readyPromise) { |
41 | | - // IDE does not understand defineProperty so declare traditionally |
42 | | - // to avoid lots of IDE warnings about invalid properties |
| 41 | + // These are private config props and functions used internally |
| 42 | + // they are collected here to reduce clutter in console.log and forEach |
43 | 43 | this.$$conf = { |
44 | 44 | promise: readyPromise, |
45 | 45 | inst: $firebase, |
46 | | - bound: null, |
| 46 | + binding: new ThreeWayBinding(this), |
47 | 47 | destroyFn: destroyFn, |
48 | 48 | listeners: [] |
49 | 49 | }; |
50 | 50 |
|
51 | 51 | // this bit of magic makes $$conf non-enumerable and non-configurable |
52 | 52 | // and non-writable (its properties are still writable but the ref cannot be replaced) |
| 53 | + // we declare it above so the IDE can relax |
53 | 54 | Object.defineProperty(this, '$$conf', { |
54 | 55 | value: this.$$conf |
55 | 56 | }); |
|
66 | 67 | * @returns a promise which will resolve after the save is completed. |
67 | 68 | */ |
68 | 69 | $save: function () { |
69 | | - var notify = this.$$notify.bind(this); |
70 | | - return this.$inst().$set($firebaseUtils.toJSON(this)) |
| 70 | + var self = this; |
| 71 | + return self.$inst().$set($firebaseUtils.toJSON(self)) |
71 | 72 | .then(function(ref) { |
72 | | - notify(); |
| 73 | + self.$$notify(); |
73 | 74 | return ref; |
74 | 75 | }); |
75 | 76 | }, |
|
122 | 123 | $bindTo: function (scope, varName) { |
123 | 124 | var self = this; |
124 | 125 | return self.$loaded().then(function () { |
125 | | - //todo split this into a subclass and shorten this method |
126 | | - //todo add comments and explanations |
127 | | - if (self.$$conf.bound) { |
128 | | - $log.error('Can only bind to one scope variable at a time'); |
129 | | - return $firebaseUtils.reject('Can only bind to one scope variable at a time'); |
130 | | - } |
131 | | - |
132 | | - var unbind = function () { |
133 | | - if (self.$$conf.bound) { |
134 | | - self.$$conf.bound = null; |
135 | | - off(); |
136 | | - } |
137 | | - }; |
138 | | - |
139 | | - // expose a few useful methods to other methods |
140 | | - var parsed = $parse(varName); |
141 | | - var $bound = self.$$conf.bound = { |
142 | | - update: function() { |
143 | | - var curr = $firebaseUtils.parseScopeData(self); |
144 | | - parsed.assign(scope, curr); |
145 | | - }, |
146 | | - get: function () { |
147 | | - return parsed(scope); |
148 | | - }, |
149 | | - unbind: unbind |
150 | | - }; |
151 | | - |
152 | | - $bound.update(); |
153 | | - scope.$on('$destroy', $bound.unbind); |
154 | | - |
155 | | - // monitor scope for any changes |
156 | | - var off = scope.$watch(varName, function () { |
157 | | - var newData = $firebaseUtils.toJSON($bound.get()); |
158 | | - self.$inst().$set(newData); |
159 | | - }, true); |
160 | | - |
161 | | - return unbind; |
| 126 | + return self.$$conf.binding.bindTo(scope, varName); |
162 | 127 | }); |
163 | 128 | }, |
164 | 129 |
|
|
195 | 160 | var self = this; |
196 | 161 | if (!self.$isDestroyed) { |
197 | 162 | self.$isDestroyed = true; |
198 | | - if (self.$$conf.bound) { |
199 | | - self.$$conf.bound.unbind(); |
200 | | - } |
| 163 | + self.$$conf.binding.destroy(); |
201 | 164 | $firebaseUtils.each(self, function (v, k) { |
202 | 165 | delete self[k]; |
203 | 166 | }); |
|
234 | 197 | this.$destroy(err); |
235 | 198 | }, |
236 | 199 |
|
| 200 | + /** |
| 201 | + * Called internally by $bindTo when data is changed in $scope. |
| 202 | + * Should apply updates to this record but should not call |
| 203 | + * notify(). |
| 204 | + */ |
| 205 | + $$scopeUpdated: function(newData) { |
| 206 | + // we use a one-directional loop to avoid feedback with 3-way bindings |
| 207 | + // since set() is applied locally anyway, this is still performant |
| 208 | + return this.$inst().$set($firebaseUtils.toJSON(newData)); |
| 209 | + }, |
| 210 | + |
237 | 211 | /** |
238 | 212 | * Updates any bound scope variables and notifies listeners registered |
239 | 213 | * with $watch any time there is a change to data |
240 | 214 | */ |
241 | 215 | $$notify: function() { |
242 | | - var self = this; |
243 | | - if( self.$$conf.bound ) { |
244 | | - self.$$conf.bound.update(); |
245 | | - } |
| 216 | + var self = this, list = this.$$conf.listeners.slice(); |
246 | 217 | // be sure to do this after setting up data and init state |
247 | | - angular.forEach(self.$$conf.listeners, function (parts) { |
| 218 | + angular.forEach(list, function (parts) { |
248 | 219 | parts[0].call(parts[1], {event: 'value', key: self.$id}); |
249 | 220 | }); |
250 | 221 | }, |
|
296 | 267 | return $firebaseUtils.inherit(ChildClass, FirebaseObject, methods); |
297 | 268 | }; |
298 | 269 |
|
| 270 | + /** |
| 271 | + * Creates a three-way data binding on a scope variable. |
| 272 | + * |
| 273 | + * @param {FirebaseObject} rec |
| 274 | + * @returns {*} |
| 275 | + * @constructor |
| 276 | + */ |
| 277 | + function ThreeWayBinding(rec) { |
| 278 | + this.subs = []; |
| 279 | + this.scope = null; |
| 280 | + this.name = null; |
| 281 | + this.rec = rec; |
| 282 | + } |
| 283 | + |
| 284 | + ThreeWayBinding.prototype = { |
| 285 | + assertNotBound: function(varName) { |
| 286 | + if( this.scope ) { |
| 287 | + var msg = 'Cannot bind to ' + varName + ' because this instance is already bound to ' + |
| 288 | + this.name + '; one binding per instance ' + |
| 289 | + '(call unbind method or create another $firebase instance)'; |
| 290 | + $log.error(msg); |
| 291 | + return $firebaseUtils.reject(msg); |
| 292 | + } |
| 293 | + }, |
| 294 | + |
| 295 | + bindTo: function(scope, varName) { |
| 296 | + function _bind(self) { |
| 297 | + var sending = false; |
| 298 | + var parsed = $parse(varName); |
| 299 | + var rec = self.rec; |
| 300 | + self.scope = scope; |
| 301 | + self.varName = varName; |
| 302 | + |
| 303 | + function equals(rec) { |
| 304 | + var parsed = getScope(); |
| 305 | + var newData = $firebaseUtils.scopeData(rec); |
| 306 | + return angular.equals(parsed, newData) && |
| 307 | + parsed.$priority === rec.$priority && |
| 308 | + parsed.$value === rec.$value; |
| 309 | + } |
| 310 | + |
| 311 | + function getScope() { |
| 312 | + return $firebaseUtils.scopeData(parsed(scope)); |
| 313 | + } |
| 314 | + |
| 315 | + function setScope(rec) { |
| 316 | + parsed.assign(scope, $firebaseUtils.scopeData(rec)); |
| 317 | + } |
| 318 | + |
| 319 | + var scopeUpdated = function() { |
| 320 | + var send = $firebaseUtils.debounce(function() { |
| 321 | + rec.$$scopeUpdated(getScope()) |
| 322 | + ['finally'](function() { sending = false; }); |
| 323 | + }, 50, 500); |
| 324 | + if( !equals(rec) ) { |
| 325 | + sending = true; |
| 326 | + send(); |
| 327 | + } |
| 328 | + }; |
| 329 | + |
| 330 | + var recUpdated = function() { |
| 331 | + if( !sending && !equals(rec) ) { |
| 332 | + setScope(rec); |
| 333 | + } |
| 334 | + }; |
| 335 | + |
| 336 | + // $watch will not check any vars prefixed with $, so we |
| 337 | + // manually check $priority and $value using this method |
| 338 | + function checkMetaVars() { |
| 339 | + var dat = parsed(scope); |
| 340 | + if( dat.$value !== rec.$value || dat.$priority !== rec.$priority ) { |
| 341 | + scopeUpdated(); |
| 342 | + } |
| 343 | + } |
| 344 | + |
| 345 | + // Okay, so this magic hack is um... magic. It increments a |
| 346 | + // variable every 50 seconds (counterKey) so that whenever $digest |
| 347 | + // is run, the variable will be dirty. This allows us to determine |
| 348 | + // when $digest is invoked, manually check the meta vars, and |
| 349 | + // manually invoke our watcher if the $ prefixed data has changed |
| 350 | + (function() { |
| 351 | + // create a counter and store it in scope |
| 352 | + var counterKey = '_firebaseCounterForVar'+varName; |
| 353 | + scope[counterKey] = 0; |
| 354 | + // update the counter every 51ms |
| 355 | + // why 51? because it must be greater than scopeUpdated's debounce |
| 356 | + // or protractor has a conniption |
| 357 | + var to = $interval(function() { |
| 358 | + scope[counterKey]++; |
| 359 | + }, 51, 0, false); |
| 360 | + // watch the counter for changes (which means $digest ran) |
| 361 | + self.subs.push(scope.$watch(counterKey, checkMetaVars)); |
| 362 | + // cancel our interval and clear var from scope if unbound |
| 363 | + self.subs.push(function() { |
| 364 | + $interval.cancel(to); |
| 365 | + delete scope[counterKey]; |
| 366 | + }); |
| 367 | + })(); |
| 368 | + |
| 369 | + setScope(rec); |
| 370 | + self.subs.push(scope.$on('$destroy', self.unbind.bind(self))); |
| 371 | + |
| 372 | + // monitor scope for any changes |
| 373 | + self.subs.push(scope.$watch(varName, scopeUpdated, true)); |
| 374 | + |
| 375 | + // monitor the object for changes |
| 376 | + self.subs.push(rec.$watch(recUpdated)); |
| 377 | + |
| 378 | + return self.unbind.bind(self); |
| 379 | + } |
| 380 | + |
| 381 | + return this.assertNotBound(varName) || _bind(this); |
| 382 | + }, |
| 383 | + |
| 384 | + unbind: function() { |
| 385 | + if( this.scope ) { |
| 386 | + angular.forEach(this.subs, function(unbind) { |
| 387 | + unbind(); |
| 388 | + }); |
| 389 | + this.subs = []; |
| 390 | + this.scope = null; |
| 391 | + this.name = null; |
| 392 | + } |
| 393 | + }, |
| 394 | + |
| 395 | + destroy: function() { |
| 396 | + this.unbind(); |
| 397 | + this.rec = null; |
| 398 | + } |
| 399 | + }; |
| 400 | + |
299 | 401 | return FirebaseObject; |
300 | 402 | } |
301 | 403 | ]); |
|
0 commit comments