1 /** 2 * Olives http://flams.github.com/olives 3 * The MIT License (MIT) 4 * Copyright (c) 2012-2014 Olivier Scherrer <pode.fr@gmail.com> - Olivier Wietrich <olivier.wietrich@gmail.com> 5 */ 6 "use strict"; 7 8 9 var Store = require("emily").Store, 10 Observable = require("emily").Observable, 11 Tools = require("emily").Tools, 12 DomUtils = require("./DomUtils"); 13 14 /** 15 * @class 16 * This plugin links dom nodes to a model 17 * @requires Store, Observable, Tools, DomUtils 18 */ 19 module.exports = function BindPluginConstructor($model, $bindings) { 20 21 /** 22 * The model to watch 23 * @private 24 */ 25 var _model = null, 26 27 /** 28 * The list of custom bindings 29 * @private 30 */ 31 _bindings = {}, 32 33 /** 34 * The list of itemRenderers 35 * each foreach has its itemRenderer 36 * @private 37 */ 38 _itemRenderers = {}, 39 40 /** 41 * The observers handlers 42 * @private 43 */ 44 _observers = {}; 45 46 /** 47 * Exposed for debugging purpose 48 * @private 49 */ 50 this.observers = _observers; 51 52 function _removeObserversForId(id) { 53 if (_observers[id]) { 54 _observers[id].forEach(function (handler) { 55 _model.unwatchValue(handler); 56 }); 57 delete _observers[id]; 58 } 59 } 60 61 /** 62 * Define the model to watch for 63 * @param {Store} model the model to watch for changes 64 * @returns {Boolean} true if the model was set 65 */ 66 this.setModel = function setModel(model) { 67 if (model instanceof Store) { 68 // Set the model 69 _model = model; 70 return true; 71 } else { 72 return false; 73 } 74 }; 75 76 /** 77 * Get the store that is watched for 78 * for debugging only 79 * @private 80 * @returns the Store 81 */ 82 this.getModel = function getModel() { 83 return _model; 84 }; 85 86 /** 87 * The item renderer defines a dom node that can be duplicated 88 * It is made available for debugging purpose, don't use it 89 * @private 90 */ 91 this.ItemRenderer = function ItemRenderer($plugins, $rootNode) { 92 93 /** 94 * The node that will be cloned 95 * @private 96 */ 97 var _node = null, 98 99 /** 100 * The object that contains plugins.name and plugins.apply 101 * @private 102 */ 103 _plugins = null, 104 105 /** 106 * The _rootNode where to append the created items 107 * @private 108 */ 109 _rootNode = null, 110 111 /** 112 * The lower boundary 113 * @private 114 */ 115 _start = null, 116 117 /** 118 * The number of item to display 119 * @private 120 */ 121 _nb = null; 122 123 /** 124 * Set the duplicated node 125 * @private 126 */ 127 this.setRenderer = function setRenderer(node) { 128 _node = node; 129 return true; 130 }; 131 132 /** 133 * Returns the node that is going to be used for rendering 134 * @private 135 * @returns the node that is duplicated 136 */ 137 this.getRenderer = function getRenderer() { 138 return _node; 139 }; 140 141 /** 142 * Sets the rootNode and gets the node to copy 143 * @private 144 * @param {HTMLElement|SVGElement} rootNode 145 * @returns 146 */ 147 this.setRootNode = function setRootNode(rootNode) { 148 var renderer; 149 if (DomUtils.isAcceptedType(rootNode)) { 150 _rootNode = rootNode; 151 renderer = _rootNode.querySelector("*"); 152 this.setRenderer(renderer); 153 if (renderer) { 154 _rootNode.removeChild(renderer); 155 } 156 return true; 157 } else { 158 return false; 159 } 160 }; 161 162 /** 163 * Gets the rootNode 164 * @private 165 * @returns _rootNode 166 */ 167 this.getRootNode = function getRootNode() { 168 return _rootNode; 169 }; 170 171 /** 172 * Set the plugins objet that contains the name and the apply function 173 * @private 174 * @param plugins 175 * @returns true 176 */ 177 this.setPlugins = function setPlugins(plugins) { 178 _plugins = plugins; 179 return true; 180 }; 181 182 /** 183 * Get the plugins object 184 * @private 185 * @returns the plugins object 186 */ 187 this.getPlugins = function getPlugins() { 188 return _plugins; 189 }; 190 191 /** 192 * The nodes created from the items are stored here 193 * @private 194 */ 195 this.items = {}; 196 197 /** 198 * Set the start limit 199 * @private 200 * @param {Number} start the value to start rendering the items from 201 * @returns the value 202 */ 203 this.setStart = function setStart(start) { 204 _start = parseInt(start, 10); 205 return _start; 206 }; 207 208 /** 209 * Get the start value 210 * @private 211 * @returns the start value 212 */ 213 this.getStart = function getStart() { 214 return _start; 215 }; 216 217 /** 218 * Set the number of item to display 219 * @private 220 * @param {Number/String} nb the number of item to display or "*" for all 221 * @returns the value 222 */ 223 this.setNb = function setNb(nb) { 224 _nb = nb == "*" ? nb : parseInt(nb, 10); 225 return _nb; 226 }; 227 228 /** 229 * Get the number of item to display 230 * @private 231 * @returns the value 232 */ 233 this.getNb = function getNb() { 234 return _nb; 235 }; 236 237 /** 238 * Adds a new item and adds it in the items list 239 * @private 240 * @param {Number} id the id of the item 241 * @returns 242 */ 243 this.addItem = function addItem(id) { 244 var node, 245 next; 246 247 if (typeof id == "number" && !this.items[id]) { 248 next = this.getNextItem(id); 249 node = this.create(id); 250 if (node) { 251 // IE (until 9) apparently fails to appendChild when insertBefore's second argument is null, hence this. 252 if (next) { 253 _rootNode.insertBefore(node, next); 254 } else { 255 _rootNode.appendChild(node); 256 } 257 return true; 258 } else { 259 return false; 260 } 261 } else { 262 return false; 263 } 264 }; 265 266 /** 267 * Get the next item in the item store given an id. 268 * @private 269 * @param {Number} id the id to start from 270 * @returns 271 */ 272 this.getNextItem = function getNextItem(id) { 273 var keys = Object.keys(this.items).map(function (string) { 274 return Number(string); 275 }), 276 closest = Tools.closestGreater(id, keys), 277 closestId = keys[closest]; 278 279 // Only return if different 280 if (closestId != id) { 281 return this.items[closestId]; 282 } else { 283 return; 284 } 285 }; 286 287 /** 288 * Remove an item from the dom and the items list 289 * @private 290 * @param {Number} id the id of the item to remove 291 * @returns 292 */ 293 this.removeItem = function removeItem(id) { 294 var item = this.items[id]; 295 if (item) { 296 _rootNode.removeChild(item); 297 delete this.items[id]; 298 _removeObserversForId(id); 299 return true; 300 } else { 301 return false; 302 } 303 }; 304 305 /** 306 * create a new node. Actually makes a clone of the initial one 307 * and adds pluginname_id to each node, then calls plugins.apply to apply all plugins 308 * @private 309 * @param id 310 * @param pluginName 311 * @returns the associated node 312 */ 313 this.create = function create(id) { 314 if (_model.has(id)) { 315 var newNode = _node.cloneNode(true), 316 nodes = DomUtils.getNodes(newNode); 317 318 Tools.toArray(nodes).forEach(function (child) { 319 child.setAttribute("data-" + _plugins.name+"_id", id); 320 }); 321 322 this.items[id] = newNode; 323 _plugins.apply(newNode); 324 return newNode; 325 } 326 }; 327 328 /** 329 * Renders the dom tree, adds nodes that are in the boundaries 330 * and removes the others 331 * @private 332 * @returns true boundaries are set 333 */ 334 this.render = function render() { 335 // If the number of items to render is all (*) 336 // Then get the number of items 337 var _tmpNb = _nb == "*" ? _model.getNbItems() : _nb; 338 339 // This will store the items to remove 340 var marked = []; 341 342 // Render only if boundaries have been set 343 if (_nb !== null && _start !== null) { 344 345 // Loop through the existing items 346 Tools.loop(this.items, function (value, idx) { 347 // If an item is out of the boundary 348 idx = Number(idx); 349 350 if (idx < _start || idx >= (_start + _tmpNb) || !_model.has(idx)) { 351 // Mark it 352 marked.push(idx); 353 } 354 }, this); 355 356 // Remove the marked item from the highest id to the lowest 357 // Doing this will avoid the id change during removal 358 // (removing id 2 will make id 3 becoming 2) 359 marked.sort(Tools.compareNumbers).reverse().forEach(this.removeItem, this); 360 361 // Now that we have removed the old nodes 362 // Add the missing one 363 for (var i=_start, l=_tmpNb+_start; i<l; i++) { 364 this.addItem(i); 365 } 366 return true; 367 } else { 368 return false; 369 } 370 }; 371 372 this.setPlugins($plugins); 373 this.setRootNode($rootNode); 374 }; 375 376 /** 377 * Save an itemRenderer according to its id 378 * @private 379 * @param {String} id the id of the itemRenderer 380 * @param {ItemRenderer} itemRenderer an itemRenderer object 381 */ 382 this.setItemRenderer = function setItemRenderer(id, itemRenderer) { 383 id = id || "default"; 384 _itemRenderers[id] = itemRenderer; 385 }; 386 387 /** 388 * Get an itemRenderer 389 * @private 390 * @param {String} id the name of the itemRenderer 391 * @returns the itemRenderer 392 */ 393 this.getItemRenderer = function getItemRenderer(id) { 394 return _itemRenderers[id]; 395 }; 396 397 /** 398 * Expands the inner dom nodes of a given dom node, filling it with model's values 399 * @param {HTMLElement|SVGElement} node the dom node to apply foreach to 400 */ 401 this.foreach = function foreach(node, idItemRenderer, start, nb) { 402 var itemRenderer = new this.ItemRenderer(this.plugins, node); 403 404 itemRenderer.setStart(start || 0); 405 itemRenderer.setNb(nb || "*"); 406 407 itemRenderer.render(); 408 409 // Add the newly created item 410 _model.watch("added", itemRenderer.render, itemRenderer); 411 412 // If an item is deleted 413 _model.watch("deleted", function (idx) { 414 itemRenderer.render(); 415 // Also remove all observers 416 _removeObserversForId(idx); 417 },this); 418 419 this.setItemRenderer(idItemRenderer, itemRenderer); 420 }; 421 422 /** 423 * Update the lower boundary of a foreach 424 * @param {String} id the id of the foreach to update 425 * @param {Number} start the new value 426 * @returns true if the foreach exists 427 */ 428 this.updateStart = function updateStart(id, start) { 429 var itemRenderer = this.getItemRenderer(id); 430 if (itemRenderer) { 431 itemRenderer.setStart(start); 432 return true; 433 } else { 434 return false; 435 } 436 }; 437 438 /** 439 * Update the number of item to display in a foreach 440 * @param {String} id the id of the foreach to update 441 * @param {Number} nb the number of items to display 442 * @returns true if the foreach exists 443 */ 444 this.updateNb = function updateNb(id, nb) { 445 var itemRenderer = this.getItemRenderer(id); 446 if (itemRenderer) { 447 itemRenderer.setNb(nb); 448 return true; 449 } else { 450 return false; 451 } 452 }; 453 454 /** 455 * Refresh a foreach after having modified its limits 456 * @param {String} id the id of the foreach to refresh 457 * @returns true if the foreach exists 458 */ 459 this.refresh = function refresh(id) { 460 var itemRenderer = this.getItemRenderer(id); 461 if (itemRenderer) { 462 itemRenderer.render(); 463 return true; 464 } else { 465 return false; 466 } 467 }; 468 469 /** 470 * Both ways binding between a dom node attributes and the model 471 * @param {HTMLElement|SVGElement} node the dom node to apply the plugin to 472 * @param {String} name the name of the property to look for in the model's value 473 * @returns 474 */ 475 this.bind = function bind(node, property, name) { 476 477 // Name can be unset if the value of a row is plain text 478 name = name || ""; 479 480 // In case of an array-like model the id is the index of the model's item to look for. 481 // The _id is added by the foreach function 482 var id = node.getAttribute("data-" + this.plugins.name+"_id"), 483 484 // Else, it is the first element of the following 485 split = name.split("."), 486 487 // So the index of the model is either id or the first element of split 488 modelIdx = id || split.shift(), 489 490 // And the name of the property to look for in the value is 491 prop = id ? name : split.join("."), 492 493 // Get the model's value 494 get = Tools.getNestedProperty(_model.get(modelIdx), prop), 495 496 // When calling bind like bind:newBinding,param1, param2... we need to get them 497 extraParam = Tools.toArray(arguments).slice(3); 498 499 // 0 and false are acceptable falsy values 500 if (get || get === 0 || get === false) { 501 // If the binding hasn't been overriden 502 if (!this.execBinding.apply(this, 503 [node, property, get] 504 // Extra params are passed to the new binding too 505 .concat(extraParam))) { 506 // Execute the default one which is a simple assignation 507 //node[property] = get; 508 DomUtils.setAttribute(node, property, get); 509 } 510 } 511 512 // Only watch for changes (double way data binding) if the binding 513 // has not been redefined 514 if (!this.hasBinding(property)) { 515 node.addEventListener("change", function (event) { 516 if (_model.has(modelIdx)) { 517 if (prop) { 518 _model.update(modelIdx, name, node[property]); 519 } else { 520 _model.set(modelIdx, node[property]); 521 } 522 } 523 }, true); 524 525 } 526 527 // Watch for changes 528 this.observers[modelIdx] = this.observers[modelIdx] || []; 529 this.observers[modelIdx].push(_model.watchValue(modelIdx, function (value) { 530 if (!this.execBinding.apply(this, 531 [node, property, Tools.getNestedProperty(value, prop)] 532 // passing extra params too 533 .concat(extraParam))) { 534 //node[property] = Tools.getNestedProperty(value, prop); 535 DomUtils.setAttribute(node, property, Tools.getNestedProperty(value, prop)); 536 } 537 }, this)); 538 539 }; 540 541 /** 542 * Set the node's value into the model, the name is the model's property 543 * @private 544 * @param {HTMLElement|SVGElement} node 545 * @returns true if the property is added 546 */ 547 this.set = function set(node) { 548 if (DomUtils.isAcceptedType(node) && node.name) { 549 _model.set(node.name, node.value); 550 return true; 551 } else { 552 return false; 553 } 554 }; 555 556 this.getItemIndex = function getElementId(dom) { 557 var dataset = DomUtils.getDataset(dom); 558 559 if (dataset && typeof dataset[this.plugins.name + "_id"] != "undefined") { 560 return +dataset[this.plugins.name + "_id"]; 561 } else { 562 return false; 563 } 564 }; 565 566 /** 567 * Prevents the submit and set the model with all form's inputs 568 * @param {HTMLFormElement} DOMfrom 569 * @returns true if valid form 570 */ 571 this.form = function form(DOMform) { 572 if (DOMform && DOMform.nodeName == "FORM") { 573 var that = this; 574 DOMform.addEventListener("submit", function (event) { 575 Tools.toArray(DOMform.querySelectorAll("[name]")).forEach(that.set, that); 576 event.preventDefault(); 577 }, true); 578 return true; 579 } else { 580 return false; 581 } 582 }; 583 584 /** 585 * Add a new way to handle a binding 586 * @param {String} name of the binding 587 * @param {Function} binding the function to handle the binding 588 * @returns 589 */ 590 this.addBinding = function addBinding(name, binding) { 591 if (name && typeof name == "string" && typeof binding == "function") { 592 _bindings[name] = binding; 593 return true; 594 } else { 595 return false; 596 } 597 }; 598 599 /** 600 * Execute a binding 601 * Only used by the plugin 602 * @private 603 * @param {HTMLElement} node the dom node on which to execute the binding 604 * @param {String} name the name of the binding 605 * @param {Any type} value the value to pass to the function 606 * @returns 607 */ 608 this.execBinding = function execBinding(node, name) { 609 if (this.hasBinding(name)) { 610 _bindings[name].apply(node, Array.prototype.slice.call(arguments, 2)); 611 return true; 612 } else { 613 return false; 614 } 615 }; 616 617 /** 618 * Check if the binding exists 619 * @private 620 * @param {String} name the name of the binding 621 * @returns 622 */ 623 this.hasBinding = function hasBinding(name) { 624 return _bindings.hasOwnProperty(name); 625 }; 626 627 /** 628 * Get a binding 629 * For debugging only 630 * @private 631 * @param {String} name the name of the binding 632 * @returns 633 */ 634 this.getBinding = function getBinding(name) { 635 return _bindings[name]; 636 }; 637 638 /** 639 * Add multiple binding at once 640 * @param {Object} list the list of bindings to add 641 * @returns 642 */ 643 this.addBindings = function addBindings(list) { 644 return Tools.loop(list, function (binding, name) { 645 this.addBinding(name, binding); 646 }, this); 647 }; 648 649 // Inits the model 650 this.setModel($model); 651 // Inits bindings 652 this.addBindings($bindings); 653 }; 654