UNPKG

data-binding-plugin

Version:

One of the most powerful two way data-binding tool with virtualization. Highly optimized for performance and memory

666 lines (585 loc) 19.3 kB
/** * @license data-binding-plugin https://github.com/flams/data-binding-plugin * * The MIT License (MIT) * * Copyright (c) 2014-2015 Olivier Scherrer <pode.fr@gmail.com> */ "use strict"; var Observable = require("watch-notify"), compareNumbers = require("compare-numbers"), simpleLoop = require("simple-loop"), toArray = require("to-array"), getClosest = require("get-closest"), nestedProperty = require("nested-property"), getNodes = require("get-nodes"), getDataset = require("get-dataset"); function setAttribute(node, property, value) { if ('ownerSVGElement' in node) { node.setAttribute(property, value); return true; } else if ('ownerDocument' in node) { node[property] = value; return true; } else { throw new Error("invalid element type"); } } /** * @class * This plugin links dom nodes to a model */ module.exports = function BindPluginConstructor($model, $bindings) { /** * The model to watch * @private */ var _model = null, /** * The list of custom bindings * @private */ _bindings = {}, /** * The list of itemRenderers * each foreach has its itemRenderer * @private */ _itemRenderers = {}, /** * The observers handlers * @private */ _observers = {}; /** * Exposed for debugging purpose * @private */ this.observers = _observers; function _removeObserversForId(id) { if (_observers[id]) { _observers[id].forEach(function (handler) { _model.unwatchValue(handler); }); delete _observers[id]; } } /** * Define the model to watch for * @param {Store} model the model to watch for changes * @returns {Boolean} true if the model was set */ this.setModel = function setModel(model) { _model = model; }; /** * Get the store that is watched for * for debugging only * @private * @returns the Store */ this.getModel = function getModel() { return _model; }; /** * The item renderer defines a dom node that can be duplicated * It is made available for debugging purpose, don't use it * @private */ this.ItemRenderer = function ItemRenderer($plugins, $rootNode) { /** * The node that will be cloned * @private */ var _node = null, /** * The object that contains plugins.name and plugins.apply * @private */ _plugins = null, /** * The _rootNode where to append the created items * @private */ _rootNode = null, /** * The lower boundary * @private */ _start = null, /** * The number of item to display * @private */ _nb = null; /** * Set the duplicated node * @private */ this.setRenderer = function setRenderer(node) { _node = node; return true; }; /** * Returns the node that is going to be used for rendering * @private * @returns the node that is duplicated */ this.getRenderer = function getRenderer() { return _node; }; /** * Sets the rootNode and gets the node to copy * @private * @param {HTMLElement|SVGElement} rootNode * @returns */ this.setRootNode = function setRootNode(rootNode) { var renderer; _rootNode = rootNode; renderer = _rootNode.querySelector("*"); this.setRenderer(renderer); if (renderer) { _rootNode.removeChild(renderer); } }; /** * Gets the rootNode * @private * @returns _rootNode */ this.getRootNode = function getRootNode() { return _rootNode; }; /** * Set the plugins objet that contains the name and the apply function * @private * @param plugins * @returns true */ this.setPlugins = function setPlugins(plugins) { _plugins = plugins; return true; }; /** * Get the plugins object * @private * @returns the plugins object */ this.getPlugins = function getPlugins() { return _plugins; }; /** * The nodes created from the items are stored here * @private */ this.items = {}; /** * Set the start limit * @private * @param {Number} start the value to start rendering the items from * @returns the value */ this.setStart = function setStart(start) { _start = parseInt(start, 10); return _start; }; /** * Get the start value * @private * @returns the start value */ this.getStart = function getStart() { return _start; }; /** * Set the number of item to display * @private * @param {Number/String} nb the number of item to display or "*" for all * @returns the value */ this.setNb = function setNb(nb) { _nb = nb == "*" ? nb : parseInt(nb, 10); return _nb; }; /** * Get the number of item to display * @private * @returns the value */ this.getNb = function getNb() { return _nb; }; /** * Adds a new item and adds it in the items list * @private * @param {Number} id the id of the item * @returns */ this.addItem = function addItem(id) { var node, next; if (typeof id == "number" && !this.items[id]) { next = this.getNextItem(id); node = this.create(id); if (node) { // IE (until 9) apparently fails to appendChild when insertBefore's second argument is null, hence this. if (next) { _rootNode.insertBefore(node, next); } else { _rootNode.appendChild(node); } return true; } else { return false; } } else { return false; } }; /** * Get the next item in the item store given an id. * @private * @param {Number} id the id to start from * @returns */ this.getNextItem = function getNextItem(id) { var keys = Object.keys(this.items).map(function (string) { return Number(string); }), closest = getClosest.greaterNumber(id, keys), closestId = keys[closest]; // Only return if different if (closestId != id) { return this.items[closestId]; } else { return; } }; /** * Remove an item from the dom and the items list * @private * @param {Number} id the id of the item to remove * @returns */ this.removeItem = function removeItem(id) { var item = this.items[id]; if (item) { _rootNode.removeChild(item); delete this.items[id]; _removeObserversForId(id); return true; } else { return false; } }; /** * create a new node. Actually makes a clone of the initial one * and adds pluginname_id to each node, then calls plugins.apply to apply all plugins * @private * @param id * @param pluginName * @returns the associated node */ this.create = function create(id) { if (_model.has(id)) { var newNode = _node.cloneNode(true), nodes = getNodes(newNode); toArray(nodes).forEach(function (child) { child.setAttribute("data-" + _plugins.name+"_id", id); }); this.items[id] = newNode; _plugins.apply(newNode); return newNode; } }; /** * Renders the dom tree, adds nodes that are in the boundaries * and removes the others * @private * @returns true boundaries are set */ this.render = function render() { // If the number of items to render is all (*) // Then get the number of items var _tmpNb = _nb == "*" ? _model.count() : _nb; // This will store the items to remove var marked = []; // Render only if boundaries have been set if (_nb !== null && _start !== null) { // Loop through the existing items simpleLoop(this.items, function (value, idx) { // If an item is out of the boundary idx = Number(idx); if (idx < _start || idx >= (_start + _tmpNb) || !_model.has(idx)) { // Mark it marked.push(idx); } }, this); // Remove the marked item from the highest id to the lowest // Doing this will avoid the id change during removal // (removing id 2 will make id 3 becoming 2) marked.sort(compareNumbers.desc).forEach(this.removeItem, this); // Now that we have removed the old nodes // Add the missing one for (var i=_start, l=_tmpNb+_start; i<l; i++) { this.addItem(i); } return true; } else { return false; } }; if ($plugins) { this.setPlugins($plugins); } if ($rootNode) { this.setRootNode($rootNode); } }; /** * Save an itemRenderer according to its id * @private * @param {String} id the id of the itemRenderer * @param {ItemRenderer} itemRenderer an itemRenderer object */ this.setItemRenderer = function setItemRenderer(id, itemRenderer) { id = id || "default"; _itemRenderers[id] = itemRenderer; }; /** * Get an itemRenderer * @private * @param {String} id the name of the itemRenderer * @returns the itemRenderer */ this.getItemRenderer = function getItemRenderer(id) { return _itemRenderers[id]; }; /** * Expands the inner dom nodes of a given dom node, filling it with model's values * @param {HTMLElement|SVGElement} node the dom node to apply foreach to */ this.foreach = function foreach(node, idItemRenderer, start, nb) { var itemRenderer = new this.ItemRenderer(this.plugins, node); itemRenderer.setStart(start || 0); itemRenderer.setNb(nb || "*"); itemRenderer.render(); // Add the newly created item _model.watch("added", itemRenderer.render, itemRenderer); // If an item is deleted _model.watch("deleted", function (idx) { itemRenderer.render(); // Also remove all observers _removeObserversForId(idx); },this); this.setItemRenderer(idItemRenderer, itemRenderer); }; /** * Update the lower boundary of a foreach * @param {String} id the id of the foreach to update * @param {Number} start the new value * @returns true if the foreach exists */ this.updateStart = function updateStart(id, start) { var itemRenderer = this.getItemRenderer(id); if (itemRenderer) { itemRenderer.setStart(start); return true; } else { return false; } }; /** * Update the number of item to display in a foreach * @param {String} id the id of the foreach to update * @param {Number} nb the number of items to display * @returns true if the foreach exists */ this.updateNb = function updateNb(id, nb) { var itemRenderer = this.getItemRenderer(id); if (itemRenderer) { itemRenderer.setNb(nb); return true; } else { return false; } }; /** * Refresh a foreach after having modified its limits * @param {String} id the id of the foreach to refresh * @returns true if the foreach exists */ this.refresh = function refresh(id) { var itemRenderer = this.getItemRenderer(id); if (itemRenderer) { itemRenderer.render(); return true; } else { return false; } }; /** * Both ways binding between a dom node attributes and the model * @param {HTMLElement|SVGElement} node the dom node to apply the plugin to * @param {String} name the name of the property to look for in the model's value * @returns */ this.bind = function bind(node, property, name) { // Name can be unset if the value of a row is plain text name = name || ""; // In case of an array-like model the id is the index of the model's item to look for. // The _id is added by the foreach function var id = node.getAttribute("data-" + this.plugins.name+"_id"), // Else, it is the first element of the following split = name.split("."), // So the index of the model is either id or the first element of split modelIdx = id || split.shift(), // And the name of the property to look for in the value is prop = id ? name : split.join("."), // Get the model's value get = nestedProperty.get(_model.get(modelIdx), prop), // When calling bind like bind:newBinding,param1, param2... we need to get them extraParam = toArray(arguments).slice(3); // 0 and false are acceptable falsy values if (get || get === 0 || get === false) { // If the binding hasn't been overriden if (!this.execBinding.apply(this, [node, property, get] // Extra params are passed to the new binding too .concat(extraParam))) { // Execute the default one which is a simple assignation //node[property] = get; setAttribute(node, property, get); } } // Only watch for changes (double way data binding) if the binding // has not been redefined if (!this.hasBinding(property)) { node.addEventListener("change", function (event) { if (_model.has(modelIdx)) { if (prop) { _model.update(modelIdx, name, node[property]); } else { _model.set(modelIdx, node[property]); } } }, true); } // Watch for changes this.observers[modelIdx] = this.observers[modelIdx] || []; this.observers[modelIdx].push(_model.watchValue(modelIdx, function (value) { if (!this.execBinding.apply(this, [node, property, nestedProperty.get(value, prop)] // passing extra params too .concat(extraParam))) { setAttribute(node, property, nestedProperty.get(value, prop)); } }, this)); }; /** * Set the node's value into the model, the name is the model's property * @private * @param {HTMLElement|SVGElement} node * @returns true if the property is added */ this.set = function set(node) { if (node.name) { _model.set(node.name, node.value); return true; } else { return false; } }; this.getItemIndex = function getElementId(dom) { var dataset = getDataset(dom); if (dataset && typeof dataset[this.plugins.name + "_id"] != "undefined") { return +dataset[this.plugins.name + "_id"]; } else { return false; } }; /** * Prevents the submit and set the model with all form's inputs * @param {HTMLFormElement} DOMfrom * @returns true if valid form */ this.form = function form(DOMform) { if (DOMform && DOMform.nodeName == "FORM") { var that = this; DOMform.addEventListener("submit", function (event) { toArray(DOMform.querySelectorAll("[name]")).forEach(that.set, that); event.preventDefault(); }, true); return true; } else { return false; } }; /** * Add a new way to handle a binding * @param {String} name of the binding * @param {Function} binding the function to handle the binding * @returns */ this.addBinding = function addBinding(name, binding) { if (name && typeof name == "string" && typeof binding == "function") { _bindings[name] = binding; return true; } else { return false; } }; /** * Execute a binding * Only used by the plugin * @private * @param {HTMLElement} node the dom node on which to execute the binding * @param {String} name the name of the binding * @param {Any type} value the value to pass to the function * @returns */ this.execBinding = function execBinding(node, name) { if (this.hasBinding(name)) { _bindings[name].apply(node, Array.prototype.slice.call(arguments, 2)); return true; } else { return false; } }; /** * Check if the binding exists * @private * @param {String} name the name of the binding * @returns */ this.hasBinding = function hasBinding(name) { return _bindings.hasOwnProperty(name); }; /** * Get a binding * For debugging only * @private * @param {String} name the name of the binding * @returns */ this.getBinding = function getBinding(name) { return _bindings[name]; }; /** * Add multiple binding at once * @param {Object} list the list of bindings to add * @returns */ this.addBindings = function addBindings(list) { return simpleLoop(list, function (binding, name) { this.addBinding(name, binding); }, this); }; // Inits the model this.setModel($model); // Inits bindings if ($bindings) { this.addBindings($bindings); } };